Нам предстоит подготовить исследование рынка Москвы, найти интересные особенности и презентовать полученные результаты, которые в будущем помогут инвесторам из фонда «Shut Up and Take My Money» в выборе подходящего места для открытия нового заведения общественного питания, определения его типа (кафе, ресторан, пиццерия, паб или бар) и уровня (меню, цены).
В нашем распоряжении датасет с заведениями общественного питания Москвы, составленный на основе данных сервисов Яндекс Карты и Яндекс Бизнес на лето 2022 года. Информация, размещённая в сервисе Яндекс Бизнес, могла быть добавлена пользователями или найдена в общедоступных источниках. Она носит исключительно справочный характер.
В процессе исследования мы изучим и обработаем полученные данные, посмотрим на различную статистику по районам города, типам заведений, их рейтингам, данным по средним чекам и количеству посадочных мест и др.
Как итог, мы хотим представить заказчику основные статистические закономерности рынка общественного питания Москвы с комментариями и дать рекомендации по открытию нового заведения.
# импорт библиотек
import pandas as pd
import numpy as np
import seaborn as sns
import plotly.express as px
import matplotlib.pyplot as plt
import plotly.graph_objects as go
import plotly.subplots as psub
import folium
from folium import Map, Marker, GeoJson, Choropleth, FeatureGroup
from folium.features import CustomIcon, GeoJsonTooltip
from folium.plugins import MarkerCluster
import requests
from branca.colormap import linear
import xml.etree.ElementTree as ET
!pip install gpxpy
import urllib.request
import gpxpy
Requirement already satisfied: gpxpy in c:\users\bobri\anaconda3\lib\site-packages (1.6.2)
df = pd.read_csv('moscow_places.csv')
df.sample(n=5, random_state=15)
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 3756 | Телебистро | кофейня | Москва, Никитский переулок, 7, стр. 2 | Центральный административный округ | пн-пт 07:30–23:00; сб,вс 09:00–23:00 | 55.757742 | 37.610462 | 4.4 | средние | Средний счёт:700–1000 ₽ | 850.0 | NaN | 0 | 32.0 |
| 6002 | Цхалтубо | ресторан | Москва, улица Кржижановского, 20/30к1 | Юго-Западный административный округ | пн-чт 11:30–23:30; пт,сб 11:30–00:00; вс 11:30... | 55.679201 | 37.572020 | 4.6 | NaN | NaN | NaN | NaN | 0 | NaN |
| 3539 | Академия | пиццерия | Москва, Большая Бронная улица, 2/6 | Центральный административный округ | ежедневно, 10:00–23:00 | 55.760128 | 37.597432 | 4.4 | высокие | Средний счёт:1500–2000 ₽ | 1750.0 | NaN | 1 | 43.0 |
| 1678 | Шоколадница | кофейня | Москва, Бутырская улица, 7 | Северный административный округ | ежедневно, 07:30–23:00 | 55.794423 | 37.583953 | 4.3 | средние | Цена чашки капучино:239–274 ₽ | NaN | 256.0 | 1 | 25.0 |
| 4928 | Pirogi-diana.ru | кафе | Москва, улица Большие Каменщики, 9, стр. Б | Центральный административный округ | ежедневно, 08:00–22:00 | 55.738539 | 37.655700 | 4.4 | NaN | NaN | NaN | NaN | 0 | NaN |
print(df.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 3315 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null int64 13 seats 4795 non-null float64 dtypes: float64(6), int64(1), object(7) memory usage: 919.5+ KB None
name — название заведения (строка)address — адрес заведения (строка)category — категория заведения, например «кафе», «пиццерия» или «кофейня» (строка)hours — информация о днях и часах работы (строка)lat — широта географической точки, в которой находится заведение (число с плавающей точкой)lng — долгота географической точки, в которой находится заведение (число с плавающей точкой)rating — рейтинг заведения по оценкам пользователей в Яндекс Картах (высшая оценка — 5.0) (число с плавающей точкой)price — категория цен в заведении, например «средние», «ниже среднего», «выше среднего» и так далее (строка)avg_bill — строка, которая хранит среднюю стоимость заказа в виде диапазона, например:middle_avg_bill — число с оценкой среднего чека, которое указано только для значений из столбца avg_bill, начинающихся с подстроки «Средний счёт»: (число с плавающей точкой)middle_coffee_cup — число с оценкой одной чашки капучино, которое указано только для значений из столбца avg_bill, начинающихся с подстроки «Цена одной чашки капучино»: (число с плавающей точкой)chain — число, выраженное 0 или 1, которое показывает, является ли заведение сетевым (для маленьких сетей могут встречаться ошибки): (целое число)district — административный район, в котором находится заведение, например Центральный административный округ (строка)seats — количество посадочных мест (число с плавающей точкой)print('Количество полных дубликатов записей: ', df.duplicated().sum())
Количество полных дубликатов записей: 0
Имена, категории, адреса и пр. могут совпадать, ведь есть сетевые заведения с одним названием и есть несколько заведений, расположенных по одному адресу, например, в торговых комплексах
А вот комбинация "Название - Адрес" или "Название - Широта - Долгота" встречаться не должна более 1 раза. Проверим-ка
print('Количество дубликатов по названию/широте/долготе: ', df.duplicated(subset=['name','lat','lng']).sum())
print('Количество дубликатов по названию/адресу: ', df.duplicated(subset=['name','address']).sum())
Количество дубликатов по названию/широте/долготе: 0 Количество дубликатов по названию/адресу: 0
Для правильности подсчетов нужно привести написание названий к одному стандарту. Поскольку у нас есть названия, состоящие из нескольких слов - правильно будет, чтобы каждое слово начиналось с заглавной буквы. Ну и удалим пробелы справа-слева от названий, если они есть
# Приведение строк к capital case - убираем пробелы, переводим все в прописной формат, затем делаем 1ю букву каждого слова заглавной
df['name'] = df['name'].str.strip().str.lower().str.title()
display(df[['name']].sample(10))
| name | |
|---|---|
| 7872 | Чайхана София |
| 479 | Гурмэ Ланч |
| 927 | Китчен |
| 24 | Drive Café |
| 4024 | Слепой Пью |
| 4293 | Prime Mart |
| 32 | Додо Пицца |
| 8078 | Кафе В Нии |
| 6715 | Мясо-Шмясо |
| 7576 | Чайхана Нават |
По сути, есть претензии только к типам данных в 2х колонках:
chain - принадлежность к сети. Сейчас это int64, хотя по сути должен быть booleanseats - количество посадочных мест. Сейчас это float64, а должен быть int64. Давайте их поменяем.
df['chain'] = df['chain'].astype('boolean')
# Предварительно заменим пропущенные значения на 0
df['seats'] = df['seats'].fillna(0)
df['seats'] = df['seats'].astype('int64')
print(df.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 3315 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null boolean 13 seats 8406 non-null int64 dtypes: boolean(1), float64(5), int64(1), object(7) memory usage: 870.3+ KB None
Первое, что мы должны проверить - это правильность выставления критерия chain
Для заведений, которые сетями не являются (т.е. имеют только 1 заведение с уникальным названием), мы этот критерий должны установить в False, и наоборот, сетевым заведениям проставим True
# выводим названия сетей, количество точек и сумму значений chain - для значений True они будут складываться, Fa;se = 0
chain_check = (df.groupby('name')
.agg(
records=('name', 'count'),
sum_chain=('chain', 'sum')
)
.reset_index()
)
display(chain_check)
| name | records | sum_chain | |
|---|---|---|---|
| 0 | #Кешбэккафе | 1 | 0 |
| 1 | +39 Pizzeria Mozzarella Bar | 1 | 0 |
| 2 | 1 Этаж | 1 | 0 |
| 3 | 1-Я Креветочная | 1 | 1 |
| 4 | 10 Идеальных Пицц | 3 | 3 |
| ... | ... | ... | ... |
| 5507 | Ярославский | 1 | 0 |
| 5508 | Яръ | 1 | 0 |
| 5509 | Ясмин | 1 | 0 |
| 5510 | Ясно | 1 | 0 |
| 5511 | Яуза | 1 | 0 |
5512 rows × 3 columns
Выведем их на экран
fake_chain = chain_check[(chain_check['records']==1) & (chain_check['sum_chain']>0)]
display(fake_chain.head())
print(fake_chain.shape[0])
| name | records | sum_chain | |
|---|---|---|---|
| 3 | 1-Я Креветочная | 1 | 1 |
| 25 | 4 Сезона | 1 | 1 |
| 118 | Bakery | 1 | 1 |
| 161 | Bigсуши | 1 | 1 |
| 506 | Drive | 1 | 1 |
59
59 записей с ошибочными значениями chain, давайте исправим
# Установка значения chain в False для строк из df, где названия name совпадают с именами из fake_single
df.loc[df['name'].isin(fake_chain['name']), 'chain'] = False
print(df['chain'].unique())
<BooleanArray> [False, True] Length: 2, dtype: boolean
все заведения, где records > 1, а sum_chain = 0 - ошибочно НЕ отнесены к категории "сеть" Выведем их на экран
display(chain_check[(chain_check['records']>1) & (chain_check['sum_chain']==0)].sort_values(by='records', ascending=False))
| name | records | sum_chain | |
|---|---|---|---|
| 2828 | Кафе | 189 | 0 |
| 5322 | Шаурма | 43 | 0 |
| 4175 | Ресторан | 34 | 0 |
| 4567 | Столовая | 28 | 0 |
| 3157 | Кофейня | 12 | 0 |
| ... | ... | ... | ... |
| 2544 | Донер Бистро | 2 | 0 |
| 2679 | Здоровое Питание | 2 | 0 |
| 2871 | Кафе Для Поминок | 2 | 0 |
| 2947 | Кафе Шашлык | 2 | 0 |
| 2968 | Кафе-Кулинария | 2 | 0 |
64 rows × 3 columns
Хм, тут не так просто - заведения в этом списке имеют общие, повторяющиеся названия, но очевидно, не 100% являются точками сети.
Например, Шаурма - это 43 различных заведения, торгующих шаурмой и т.д.
Поэтому такую замену мы сделать не сможем
И последний вариант - выведем все заведения, где records > 1 & records < sum_chain - ошибочно НЕ ВСЕ ТОЧКИ отнесены к категории "сеть" Выведем их на экран
chain_less = (
chain_check[(chain_check['records']>1)
& (chain_check['sum_chain']<chain_check['records'])
& (chain_check['sum_chain']>0)]
)
display(chain_less)
| name | records | sum_chain | |
|---|---|---|---|
| 51 | Abc Coffee Roasters | 5 | 4 |
| 246 | Burger Club | 6 | 5 |
| 376 | Coffee Point | 3 | 2 |
| 598 | Flip | 3 | 2 |
| 856 | Korean Chick | 6 | 5 |
| 1048 | More Poke | 3 | 2 |
| 1106 | Noba Coffee | 5 | 4 |
| 1116 | Nova Bubble Tea | 3 | 2 |
| 1137 | One Price Coffee | 72 | 71 |
| 1139 | One&Double | 5 | 4 |
| 1197 | Pho Street | 4 | 3 |
| 1252 | Poke House | 3 | 2 |
| 1289 | Raw To Go | 3 | 2 |
| 1354 | Sedelice | 3 | 2 |
| 1414 | Sova Coffee | 3 | 2 |
| 1430 | Star Hit Cafe | 7 | 6 |
| 1648 | Wave California Poke | 3 | 2 |
| 1874 | Ача-Чача | 4 | 3 |
| 2392 | Гриль Хаус | 3 | 2 |
| 2535 | Домино'С Пицца | 77 | 76 |
| 2611 | Ели Сацебели | 3 | 2 |
| 2974 | Кафе-Столовая | 11 | 2 |
| 3374 | Ливан Хаус | 2 | 1 |
| 3417 | Ля Фантази | 6 | 5 |
| 3631 | Мск Lounge | 15 | 14 |
| 3962 | Пирог Хауз | 3 | 2 |
| 4141 | Раковарня Клешни И Хвосты | 3 | 2 |
| 4145 | Рамен-Клаб | 5 | 4 |
| 4333 | Рэдимэйд | 3 | 2 |
| 4543 | Старый Город | 3 | 2 |
| 4901 | Франклинс Бургер | 9 | 8 |
| 4944 | Халва, Сеть Почтоматов | 4 | 3 |
| 5022 | Хинкальная Экспресс | 2 | 1 |
| 5036 | Хлеб Да Выпечка | 3 | 2 |
| 5139 | Чайхана Бишкек Сити | 3 | 2 |
| 5333 | Шаурма В Пите | 4 | 3 |
| 5367 | Шашлык Хаус | 3 | 2 |
| 5389 | Шашлычок | 4 | 3 |
# Установка значения chain в True для строк из df, где названия name совпадают с именами из chain_less
# за исключением Кафе-Столовая
df.loc[(df['name'].isin(chain_less['name'])) & (df['name']!='Кафе-Столовая'), 'chain'] = True
display(df.query('name == "Abc Coffee Roasters"', engine='python'))
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1442 | Abc Coffee Roasters | кофейня | Москва, Ленинградский проспект, 72, корп. 1 | Северный административный округ | пн-пт 08:00–22:00; сб,вс 10:00–22:00 | 55.805547 | 37.520395 | 4.5 | средние | Цена чашки капучино:220–270 ₽ | NaN | 245.0 | True | 625 |
| 3813 | Abc Coffee Roasters | кофейня | Москва, Большая Никитская улица, 19/16с1 | Центральный административный округ | пн-пт 08:00–22:00; сб,вс 10:00–22:00 | 55.757162 | 37.600701 | 4.4 | NaN | NaN | NaN | NaN | True | 0 |
| 4163 | Abc Coffee Roasters | кофейня | Москва, Усачёва улица, 11И | Центральный административный округ | пн-пт 08:00–22:00; сб,вс 10:00–22:00 | 55.727530 | 37.570567 | 4.4 | NaN | NaN | NaN | NaN | True | 0 |
| 4696 | Abc Coffee Roasters | кофейня | Москва, улица Покровка, 7/9-11к1, подъезд 10 | Центральный административный округ | пн-пт 08:00–22:00; сб,вс 10:00–22:00 | 55.759012 | 37.642190 | 4.4 | NaN | NaN | NaN | NaN | True | 0 |
| 5156 | Abc Coffee Roasters | кофейня | Москва, Ордынский тупик, 4А | Центральный административный округ | пн-пт 08:00–22:00; сб,вс 10:00–22:00 | 55.741026 | 37.623384 | 4.5 | NaN | NaN | NaN | NaN | True | 0 |
Проверим, не осталось ли в базе одиноких заведений, у которых chain = True
display(df.query('chain == True', engine='python').groupby('name').agg(outlets=('name', 'count')).sort_values(by='outlets', ascending=False))
| outlets | |
|---|---|
| name | |
| Шоколадница | 120 |
| Домино'С Пицца | 77 |
| Додо Пицца | 74 |
| One Price Coffee | 72 |
| Яндекс Лавка | 69 |
| ... | ... |
| Лоза | 2 |
| Литературное Кафе | 2 |
| Линдфорс | 2 |
| Лимонадница | 2 |
| Кабул | 2 |
688 rows × 1 columns
Все в порядке, теперь у нас статус сетевых имеют только заведения, имеющие более 1 точки
В наших данных есть пропуски, было бы здорово их не иметь. Давайте посмотрим, в каких столбцах они есть, как в них выглядят и откуда берутся данные и можем ли мы заменить пропуски чем-нибудь.
Приступим к замене пропусков в price
# посмоттрим, какие значения принимает колонка price
display(df['price'].unique())
array([nan, 'выше среднего', 'средние', 'высокие', 'низкие'], dtype=object)
# Сохранение ценовой категории для каждой сети
chain_price = (
df.query('chain == True', engine='python')
.sort_values(by='name', ascending=False)
.groupby('name')
.agg({'price': 'first'})
.dropna(subset=['price'])
)
# Отображение результата chain_price
display(chain_price)
# Преобразование chain_price в словарь для удобного доступа
price_mapping = chain_price['price'].to_dict()
# Заполнение пропущенных значений в столбце 'price' на основе словаря
df['price'] = df.apply(lambda row: price_mapping[row['name']] if pd.isna(row['price']) and row['name'] in price_mapping else row['price'], axis=1)
# Отображение данных обновленного DataFrame
print(df.info())
# посмоттрим, какие значения принимает колонка price
display(df['price'].unique())
| price | |
|---|---|
| name | |
| 18 Грамм | средние |
| 7 Сэндвичей | средние |
| Abc Coffee Roasters | средние |
| Air Coffee | средние |
| Americano Black Coffee & Food | средние |
| ... | ... |
| Эзо | выше среднего |
| Эль Кафе | низкие |
| Южный Дворик | средние |
| Юрта | средние |
| Ян Примус | выше среднего |
429 rows × 1 columns
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 4449 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null boolean 13 seats 8406 non-null int64 dtypes: boolean(1), float64(5), int64(1), object(7) memory usage: 870.3+ KB None
array([nan, 'выше среднего', 'средние', 'низкие', 'высокие'], dtype=object)
Попробуем 2й вариант с сопоставлением средних цен и категории.
Для начала, проверим, есть ли корреляция между ценовой категорией и средним чеком, для чего надо сначала преобразовать строковое название ценовой категории в число.
# Преобразование категорий в числовые значения
category_mapping = {'низкие': 1, 'средние': 2, 'выше среднего': 3, 'высокие': 4}
df['price_category_numeric'] = df['price'].map(category_mapping)
# Расчет корреляции
correlation = df['middle_avg_bill'].corr(df['price_category_numeric'])
print("Корреляция между средним чеком и ценовой категорией:", correlation)
Корреляция между средним чеком и ценовой категорией: 0.6520915695430131
Посмотрим также на все возможные корреляции в данных. Для этого надо сначала качественные категории превратить в цифры
df.info()
df['price'].unique()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 15 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 4449 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null boolean 13 seats 8406 non-null int64 14 price_category_numeric 4449 non-null float64 dtypes: boolean(1), float64(6), int64(1), object(7) memory usage: 936.0+ KB
array([nan, 'выше среднего', 'средние', 'низкие', 'высокие'], dtype=object)
# Преобразование категорий в числовые значения
category_mapping_price = {'выше среднего':3, 'средние':2, 'высокие':4, 'низкие':1}
df['price_category_numeric'] = df['price'].map(category_mapping_price)
category_mapping_category = {'кафе':1, 'ресторан':2, 'кофейня':3, 'пиццерия':4, 'бар,паб':5,
'быстрое питание':6, 'булочная':7, 'столовая':8}
df['category_numeric'] = df['category'].map(category_mapping_category)
category_mapping_district = {'Северный административный округ':1,
'Северо-Восточный административный округ':2,
'Северо-Западный административный округ':3,
'Западный административный округ':4,
'Центральный административный округ':5,
'Восточный административный округ':6,
'Юго-Восточный административный округ':7,
'Южный административный округ':8,
'Юго-Западный административный округ':9}
df['district_numeric'] = df['district'].map(category_mapping_district)
# Расчет корреляции
correlation = df['middle_avg_bill'].corr(df['price_category_numeric'])
print("Корреляция между средним чеком и ценовой категорией:", correlation)
Корреляция между средним чеком и ценовой категорией: 0.6520915695430131
display(df.head())
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | price_category_numeric | category_numeric | district_numeric | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Wowфли | кафе | Москва, улица Дыбенко, 7/1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.878494 | 37.478860 | 5.0 | NaN | NaN | NaN | NaN | False | 0 | NaN | 1 | 1 |
| 1 | Четыре Комнаты | ресторан | Москва, улица Дыбенко, 36, корп. 1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.875801 | 37.484479 | 4.5 | выше среднего | Средний счёт:1500–1600 ₽ | 1550.0 | NaN | False | 4 | 3.0 | 2 | 1 |
| 2 | Хазри | кафе | Москва, Клязьминская улица, 15 | Северный административный округ | пн-чт 11:00–02:00; пт,сб 11:00–05:00; вс 11:00... | 55.889146 | 37.525901 | 4.6 | средние | Средний счёт:от 1000 ₽ | 1000.0 | NaN | False | 45 | 2.0 | 1 | 1 |
| 3 | Dormouse Coffee Shop | кофейня | Москва, улица Маршала Федоренко, 12 | Северный административный округ | ежедневно, 09:00–22:00 | 55.881608 | 37.488860 | 5.0 | NaN | Цена чашки капучино:155–185 ₽ | NaN | 170.0 | False | 0 | NaN | 3 | 1 |
| 4 | Иль Марко | пиццерия | Москва, Правобережная улица, 1Б | Северный административный округ | ежедневно, 10:00–22:00 | 55.881166 | 37.449357 | 5.0 | средние | Средний счёт:400–600 ₽ | 500.0 | NaN | True | 148 | 2.0 | 4 | 1 |
# посмотрим на все корреляции визуально
# Выбор только числовых столбцов
numeric_df = df.select_dtypes(include=['number'])
# Вычисление корреляционной матрицы
corr_matrix = numeric_df.corr()
# Вывод корреляционной матрицы
print(corr_matrix)
# Создание тепловой карты корреляций
fig = go.Figure(data=go.Heatmap(
z=corr_matrix.values,
x=corr_matrix.columns,
y=corr_matrix.index,
colorscale='Viridis'
))
# Добавление заголовка и меток осей
fig.update_layout(
title='Корреляционная матрица',
xaxis_nticks=36,
yaxis_nticks=36
)
# Отображение графика
fig.show()
lat lng rating middle_avg_bill \
lat 1.000000 -0.129464 0.028340 -0.006489
lng -0.129464 1.000000 -0.017109 -0.052662
rating 0.028340 -0.017109 1.000000 0.183238
middle_avg_bill -0.006489 -0.052662 0.183238 1.000000
middle_coffee_cup -0.001345 -0.038006 0.100447 NaN
seats -0.017862 -0.089828 0.027411 0.083582
price_category_numeric -0.001433 -0.058994 0.227639 0.652092
category_numeric 0.038970 -0.002313 0.045263 -0.134645
district_numeric -0.801625 0.376403 -0.028665 -0.021425
middle_coffee_cup seats price_category_numeric \
lat -0.001345 -0.017862 -0.001433
lng -0.038006 -0.089828 -0.058994
rating 0.100447 0.027411 0.227639
middle_avg_bill NaN 0.083582 0.652092
middle_coffee_cup 1.000000 0.008485 0.472249
seats 0.008485 1.000000 0.078053
price_category_numeric 0.472249 0.078053 1.000000
category_numeric 0.053311 0.009494 -0.050837
district_numeric 0.015844 -0.037175 -0.018675
category_numeric district_numeric
lat 0.038970 -0.801625
lng -0.002313 0.376403
rating 0.045263 -0.028665
middle_avg_bill -0.134645 -0.021425
middle_coffee_cup 0.053311 0.015844
seats 0.009494 -0.037175
price_category_numeric -0.050837 -0.018675
category_numeric 1.000000 -0.027800
district_numeric -0.027800 1.000000
Из корреляционной матрицы видно, что выраженная связь есть действительно только у пары Ценовая Категория - Средний Чек
0.652 - это значит, что положительная корреляция есть (в системе, где 0 = абсолютно нет корреляции, 1 = есть положительная линейная зависимость), можно переходить к расчетам.
# Посмотрим на 25-50-75 персентили средних чеков каждой ценовой категории
for i in df['price'].unique():
print(i)
print(df.query('price == @i', engine='python')['middle_avg_bill'].quantile(0.25))
print(df.query('price == @i', engine='python')['middle_avg_bill'].quantile(0.50))
print(df.query('price == @i', engine='python')['middle_avg_bill'].quantile(0.75))
print('------------------- Next part -----------------------------')
nan nan nan nan ------------------- Next part ----------------------------- выше среднего 1250.0 1250.0 1500.0 ------------------- Next part ----------------------------- средние 350.0 500.0 800.0 ------------------- Next part ----------------------------- низкие 150.0 190.0 300.0 ------------------- Next part ----------------------------- высокие 1750.0 2000.0 2500.0 ------------------- Next part -----------------------------
no_price = df.loc[df['price'].isna() & ~df['middle_avg_bill'].isna()]
display(no_price)
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | price_category_numeric | category_numeric | district_numeric | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 27 | Шаурму Х@Чу | быстрое питание | Москва, улица Дыбенко, 7, стр. 1 | Северный административный округ | пн-пт 08:00–22:00; сб,вс 10:00–22:00 | 55.879324 | 37.480280 | 4.1 | NaN | Средний счёт:от 240 ₽ | 240.0 | NaN | False | 4 | NaN | 6 | 1 |
| 49 | 2U-Ту-Ю | пиццерия | Москва, Ижорская улица, 8А | Северный административный округ | ежедневно, круглосуточно | 55.886160 | 37.508784 | 2.7 | NaN | Средний счёт:900 ₽ | 900.0 | NaN | False | 0 | NaN | 4 | 1 |
| 84 | Meat Doner Kebab | булочная | Москва, улица Лескова, 22 | Северо-Восточный административный округ | ежедневно, круглосуточно | 55.896987 | 37.608126 | 4.5 | NaN | Средний счёт:300 ₽ | 300.0 | NaN | False | 0 | NaN | 7 | 2 |
| 113 | Bowl Family | кафе | Москва, улица Лескова, 14 | Северо-Восточный административный округ | ежедневно, 10:00–22:00 | 55.897525 | 37.604924 | 4.1 | NaN | Средний счёт:700–800 ₽ | 750.0 | NaN | True | 0 | NaN | 1 | 2 |
| 124 | Шашлык Хаус | кафе | Москва, Дмитровское шоссе, 107, корп. 4 | Северный административный округ | ежедневно, 10:00–22:00 | 55.879787 | 37.539702 | 4.2 | NaN | Средний счёт:от 200 ₽ | 200.0 | NaN | True | 79 | NaN | 1 | 1 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 8347 | Compass Coffee&Bakery | кафе | Москва, Окская улица, 1, корп. 1 | Юго-Восточный административный округ | пн-пт 10:00–22:00; сб,вс 11:00–22:00 | 55.712435 | 37.752065 | 4.0 | NaN | Средний счёт:300–400 ₽ | 350.0 | NaN | False | 80 | NaN | 1 | 7 |
| 8366 | Ситипицца | пиццерия | Москва, Ферганская улица, 12 | Юго-Восточный административный округ | ежедневно, 10:00–23:00 | 55.708143 | 37.808957 | 4.2 | NaN | Средний счёт:300–700 ₽ | 500.0 | NaN | False | 4 | NaN | 4 | 7 |
| 8383 | Pizza24/7 | пиццерия | Москва, улица Юных Ленинцев, 10/15к1 | Юго-Восточный административный округ | ежедневно, круглосуточно | 55.698824 | 37.738878 | 4.2 | NaN | Средний счёт:150 ₽ | 150.0 | NaN | False | 0 | NaN | 4 | 7 |
| 8399 | Пекарня, Кафе-Гриль | булочная | Москва, Болотниковская улица, 52, корп. 2 | Юго-Западный административный округ | ежедневно, круглосуточно | 55.662866 | 37.582572 | 4.2 | NaN | Средний счёт:50–250 ₽ | 150.0 | NaN | False | 50 | NaN | 7 | 9 |
| 8403 | Самовар | кафе | Москва, Люблинская улица, 112А, стр. 1 | Юго-Восточный административный округ | ежедневно, круглосуточно | 55.648859 | 37.743219 | 3.9 | NaN | Средний счёт:от 150 ₽ | 150.0 | NaN | False | 150 | NaN | 1 | 7 |
261 rows × 17 columns
# Создание диапазонов для каждой категории по границам 75го персентиля
bins = [0, 300, 800, 1500, np.inf]
labels = ['низкие', 'средние', 'выше среднего', 'высокие']
# Присвоение ценовой категории на основе среднего чека
no_price['calculated_price'] = pd.cut(no_price['middle_avg_bill'], bins=bins, labels=labels, right=True)
df.loc[no_price.index, 'price'] = no_price['calculated_price']
# # Удаление временной колонки
df.drop(columns=['price_category_numeric'], inplace=True)
# # Посмотрим, сколько теперь пропущенных записей
print(df.info())
# посмоттрим, какие значения принимает колонка price
display(df['price'].unique())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 16 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 4710 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null boolean 13 seats 8406 non-null int64 14 category_numeric 8406 non-null int64 15 district_numeric 8406 non-null int64 dtypes: boolean(1), float64(5), int64(3), object(7) memory usage: 1001.6+ KB None
C:\Users\bobri\AppData\Local\Temp\ipykernel_7148\554917192.py:6: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
array([nan, 'выше среднего', 'средние', 'низкие', 'высокие'], dtype=object)
Приступим к замене пропусков в avg_bill, middle_avg_bill, middle_coffee_cup
# посмотрим, какие уникальные значения есть в avg_bill (ограничим выдачу 50 значений)
print(df['avg_bill'].nunique())
print(df['avg_bill'].unique()[:50])
897 [nan 'Средний счёт:1500–1600 ₽' 'Средний счёт:от 1000 ₽' 'Цена чашки капучино:155–185 ₽' 'Средний счёт:400–600 ₽' 'Средний счёт:199 ₽' 'Средний счёт:200–300 ₽' 'Средний счёт:от 500 ₽' 'Средний счёт:1000–1200 ₽' 'Цена бокала пива:250–350 ₽' 'Средний счёт:330 ₽' 'Средний счёт:1500 ₽' 'Средний счёт:300–500 ₽' 'Средний счёт:140–350 ₽' 'Средний счёт:350–500 ₽' 'Средний счёт:300–1500 ₽' 'Средний счёт:от 240 ₽' 'Средний счёт:200–250 ₽' 'Средний счёт:328 ₽' 'Средний счёт:300 ₽' 'Средний счёт:от 345 ₽' 'Средний счёт:60–400 ₽' 'Средний счёт:900 ₽' 'Средний счёт:500–800 ₽' 'Средний счёт:500–1000 ₽' 'Средний счёт:600–700 ₽' 'Цена бокала пива:120–350 ₽' 'Средний счёт:1000–1500 ₽' 'Средний счёт:1500–2000 ₽' 'Цена чашки капучино:150–190 ₽' 'Средний счёт:2000–2500 ₽' 'Средний счёт:600 ₽' 'Средний счёт:450 ₽' 'Цена чашки капучино:120–170 ₽' 'Средний счёт:100–500 ₽' 'Средний счёт:от 850 ₽' 'Цена чашки капучино:100–200 ₽' 'Средний счёт:250–600 ₽' 'Средний счёт:2100 ₽' 'Средний счёт:349 ₽' 'Цена бокала пива:90–230 ₽' 'Цена чашки капучино:150–210 ₽' 'Средний счёт:150–250 ₽' 'Средний счёт:от 120 ₽' 'Цена чашки капучино:80–160 ₽' 'Средний счёт:269 ₽' 'Средний счёт:600–2000 ₽' 'Средний счёт:700–800 ₽' 'Средний счёт:500–700 ₽' 'Средний счёт:300–2000 ₽']
# Выбор строк, где 'avg_bill' содержит 'Средний счет' и 'middle_avg_bill' имеет NaN
result = df[df['avg_bill'].str.contains("Средний счет") & df['middle_avg_bill'].isna()]
display(result)
# Выбор строк, где 'avg_bill' содержит 'капучино' и 'middle_coffee_cup' имеет NaN
result = df[df['avg_bill'].str.contains("капучино") & df['middle_coffee_cup'].isna()]
display(result)
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | category_numeric | district_numeric |
|---|
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | category_numeric | district_numeric |
|---|
avg_bill, middle_avg_bill, middle_coffee_cupНа этом с заменой пропущенных значений закончили
Выделим из данных колонки с адресом только названия улиц и сохраним в отдельный столбец
# выведем несколько примеров данных из колонки address
print(df[['address']].head(10))
address 0 Москва, улица Дыбенко, 7/1 1 Москва, улица Дыбенко, 36, корп. 1 2 Москва, Клязьминская улица, 15 3 Москва, улица Маршала Федоренко, 12 4 Москва, Правобережная улица, 1Б 5 Москва, Ижорская улица, вл8Б 6 Москва, Клязьминская улица, 9, стр. 3 7 Москва, Клязьминская улица, 9, стр. 3 8 Москва, Дмитровское шоссе, 107, корп. 4 9 Москва, Ангарская улица, 39
df['street'] = df['address'].apply(lambda x: x.split(', ')[1])
display(df.sample(5))
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | category_numeric | district_numeric | street | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 6247 | Чайхана Зейтун | кафе | Москва, Малая Тульская улица, 57, корп. 1 | Южный административный округ | ежедневно, 09:00–00:00 | 55.700980 | 37.616678 | 4.0 | средние | Средний счёт:300–600 ₽ | 450.0 | NaN | False | 0 | 1 | 8 | Малая Тульская улица |
| 4557 | Контрразведка | бар,паб | Москва, Новая площадь, 10 | Центральный административный округ | пн-пт 12:00–00:00; сб,вс 12:00–06:00 | 55.758048 | 37.627392 | 4.8 | NaN | NaN | NaN | NaN | False | 120 | 5 | 5 | Новая площадь |
| 4627 | Cups&Hugs | кофейня | Москва, Орликов переулок, 6 | Центральный административный округ | пн-пт 08:00–20:00; сб 09:00–18:00 | 55.771137 | 37.648465 | 4.2 | средние | Средний счёт:200–350 ₽ | 275.0 | NaN | False | 40 | 3 | 5 | Орликов переулок |
| 1477 | Хлеб Насущный | булочная | Москва, улица Авиаконструктора Микояна, 12 | Северный административный округ | пн-пт 07:00–21:00; сб,вс 08:00–18:00 | 55.792487 | 37.527582 | 4.3 | NaN | NaN | NaN | NaN | True | 30 | 7 | 1 | улица Авиаконструктора Микояна |
| 8228 | Волчок | кафе | Москва, проспект Андропова, 18, корп. 9 | Южный административный округ | NaN | 55.691734 | 37.660548 | 4.6 | NaN | NaN | NaN | NaN | False | 0 | 1 | 8 | проспект Андропова |
Проверим, есть ли в колонке с часами работы hours данные про,то, что они работаю ежедневно и круглосуточно
nonstop = df[df['hours'].str.contains("дневно") & df['hours'].str.contains("суточн")]
display(nonstop)
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | category_numeric | district_numeric | street | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10 | Great Room Bar | бар,паб | Москва, Левобережная улица, 12 | Северный административный округ | ежедневно, круглосуточно | 55.877832 | 37.469171 | 4.5 | средние | Цена бокала пива:250–350 ₽ | NaN | NaN | False | 102 | 5 | 1 | Левобережная улица |
| 17 | Чайхана Беш-Бармак | ресторан | Москва, Ленинградское шоссе, 71Б, стр. 2 | Северный административный округ | ежедневно, круглосуточно | 55.876908 | 37.449876 | 4.4 | средние | Средний счёт:350–500 ₽ | 425.0 | NaN | False | 96 | 2 | 1 | Ленинградское шоссе |
| 19 | Пекарня | булочная | Москва, Ижорский проезд, 5 | Северный административный округ | ежедневно, круглосуточно | 55.887969 | 37.515688 | 4.4 | NaN | NaN | NaN | NaN | True | 0 | 7 | 1 | Ижорский проезд |
| 24 | Drive Café | кафе | Москва, улица Дыбенко, 9Ас1 | Северный административный округ | ежедневно, круглосуточно | 55.879992 | 37.481571 | 4.0 | NaN | NaN | NaN | NaN | True | 0 | 1 | 1 | улица Дыбенко |
| 49 | 2U-Ту-Ю | пиццерия | Москва, Ижорская улица, 8А | Северный административный округ | ежедневно, круглосуточно | 55.886160 | 37.508784 | 2.7 | выше среднего | Средний счёт:900 ₽ | 900.0 | NaN | False | 0 | 4 | 1 | Ижорская улица |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 8394 | Намангале | кафе | Москва, Ферганская улица, вл17-21 | Юго-Восточный административный округ | ежедневно, круглосуточно | 55.705332 | 37.819244 | 4.3 | NaN | NaN | NaN | NaN | False | 0 | 1 | 7 | Ферганская улица |
| 8399 | Пекарня, Кафе-Гриль | булочная | Москва, Болотниковская улица, 52, корп. 2 | Юго-Западный административный округ | ежедневно, круглосуточно | 55.662866 | 37.582572 | 4.2 | низкие | Средний счёт:50–250 ₽ | 150.0 | NaN | False | 50 | 7 | 9 | Болотниковская улица |
| 8403 | Самовар | кафе | Москва, Люблинская улица, 112А, стр. 1 | Юго-Восточный административный округ | ежедневно, круглосуточно | 55.648859 | 37.743219 | 3.9 | низкие | Средний счёт:от 150 ₽ | 150.0 | NaN | False | 150 | 1 | 7 | Люблинская улица |
| 8404 | Чайхана Sabr | кафе | Москва, Люблинская улица, 112А, стр. 1 | Юго-Восточный административный округ | ежедневно, круглосуточно | 55.648849 | 37.743222 | 4.2 | средние | NaN | NaN | NaN | True | 150 | 1 | 7 | Люблинская улица |
| 8405 | Kebab Time | кафе | Москва, Россошанский проезд, 6 | Южный административный округ | ежедневно, круглосуточно | 55.598229 | 37.604702 | 3.9 | NaN | NaN | NaN | NaN | False | 12 | 1 | 8 | Россошанский проезд |
730 rows × 17 columns
# Cоздадим столбец is_24_7, используя метод isin()
df['is_24_7'] = df.index.isin(nonstop.index)
# А прочим строкам присвоим False
df.loc[~df.index.isin(nonstop.index), 'is_24_7'] = False
display(df.sample(10))
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | category_numeric | district_numeric | street | is_24_7 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1318 | Jeffrey’S Coffeeshop | кофейня | Москва, Волоколамское шоссе, 11, стр. 2 | Северный административный округ | пн-пт 09:00–18:00; сб 09:00–15:00 | 55.806194 | 37.497629 | 4.3 | NaN | NaN | NaN | NaN | False | 188 | 3 | 1 | Волоколамское шоссе | False |
| 5496 | Домино'С Пицца | пиццерия | Москва, Вешняковская улица, 17Б | Восточный административный округ | ежедневно, 11:00–00:00 | 55.734511 | 37.832248 | 4.2 | средние | Средний счёт:от 500 ₽ | 500.0 | NaN | True | 14 | 4 | 6 | Вешняковская улица | False |
| 1673 | Суши Пятница | кафе | Москва, Тимирязевская улица, 11 | Северный административный округ | ежедневно, 10:00–23:00 | 55.809741 | 37.569163 | 4.4 | средние | Средний счёт:300–650 ₽ | 475.0 | NaN | False | 50 | 1 | 1 | Тимирязевская улица | False |
| 2503 | Бистро | кафе | Москва, Сокольническая площадь, 4к1-2 | Восточный административный округ | ежедневно, круглосуточно | 55.789802 | 37.678150 | 4.1 | NaN | NaN | NaN | NaN | False | 0 | 1 | 6 | Сокольническая площадь | True |
| 6964 | Lunch&Box | столовая | Москва, Профсоюзная улица, 65, корп. 1 | Юго-Западный административный округ | пн-пт 08:00–20:00 | 55.653225 | 37.538190 | 4.2 | средние | Средний счёт:от 300 ₽ | 300.0 | NaN | False | 150 | 8 | 9 | Профсоюзная улица | False |
| 1749 | Sattva | ресторан | Москва, Ленинградский проспект, 52 | Северный административный округ | ежедневно, 10:00–23:00 | 55.797490 | 37.540197 | 4.4 | средние | Средний счёт:400–1500 ₽ | 950.0 | NaN | True | 0 | 2 | 1 | Ленинградский проспект | False |
| 3703 | Brodo Bar & Kitchen | ресторан | Москва, Петровский бульвар, 2 | Центральный административный округ | ежедневно, 12:00–00:00 | 55.767846 | 37.614555 | 4.9 | высокие | Средний счёт:1500–2000 ₽ | 1750.0 | NaN | False | 0 | 2 | 5 | Петровский бульвар | False |
| 8034 | Чудо-Печка | столовая | Москва, улица Трофимова, 33 | Юго-Восточный административный округ | пн-пт 08:00–00:00; сб 09:00–00:00; вс 09:30–00:00 | 55.703444 | 37.683444 | 4.4 | средние | Средний счёт:300–350 ₽ | 325.0 | NaN | False | 40 | 8 | 7 | улица Трофимова | False |
| 7186 | Fast Шашлык | ресторан | Москва, Каширское шоссе, 25Б | Южный административный округ | ежедневно, 09:00–22:00 | 55.656372 | 37.647862 | 3.2 | NaN | NaN | NaN | NaN | False | 24 | 2 | 8 | Каширское шоссе | False |
| 4511 | Гамбринус | бар,паб | Москва, Большая Спасская улица, 29 | Центральный административный округ | пн-пт 11:00–00:00; сб,вс 12:00–00:00 | 55.776424 | 37.643578 | 4.7 | выше среднего | NaN | NaN | NaN | True | 0 | 5 | 5 | Большая Спасская улица | False |
В процессе анализа и первичной обработки данных мы предприняли следующие шаги:
chain и seats на boolean и int64 соответственноseats на 0price на расчетные значенияchainstreet с названиями улиц, выделенными из адресаis_24_7, в котором хранится признак True|False того, рабоает ли точка 24/7categories = df.groupby('category').agg({'name':'count'}).sort_values(by='name', ascending=True)
# Вычисление общего количества точек
total_counts = categories['name'].sum()
# Вычисление процента для каждой категории
percentages = categories['name'] / total_counts * 100
# Добавление горизонтальных столбцов на график
fig = go.Figure()
fig.add_trace(go.Bar(
x=categories['name'],
y=categories.index,
orientation='h', # Горизонтальная ориентация
marker=dict(color='indianred'), # Цвет столбцов
name='Количество точек'
))
# Добавление текста с процентными значениями на график
for i, (count, percentage) in enumerate(zip(categories['name'], percentages)):
fig.add_annotation(
x=count + 70, # Положение текста на графике (немного правее конца столбца)
y=categories.index[i], # Положение текста по вертикали (на уровне соответствующей категории)
text=f'{percentage:.1f}%', # Форматированный текст с процентом
showarrow=False, # Без стрелки
font=dict(color='black', size=12) # Цвет и размер текста
)
# Настройка макета
fig.update_layout(
title={
'text': 'Количество точек по категориям заведения с процентами от общего их числа',
'x': 0.5, # Расположение заголовка (0: левый край, 0.5: центр, 1: правый край)
'xanchor': 'center', # Анкор заголовка (соответствует расположению)
'yanchor': 'middle' # Анкор заголовка по вертикали
},
xaxis=dict(title='Количество точек'), # Настройка заголовка оси X
yaxis=dict(title='Категория') # Настройка заголовка оси Y
)
# Показать график
fig.show()
Из 8406 точек питания Москвы в 2022 году 28.3% занимал тип кафе, 24.3% - рестораны. Далее с результатом 16.8% шли кофейни
Это 3 категории-лидера по количеству точек.
Следующие категории начинались с 9.1% - бар/паб - и снижались до 3% - булочная.
# seat - заменим обратно 0 на NaN, для того, чтобы верно посчитать среднее количество
# Заменяем 0 на NaN и посмотрим на распределение значений
df['seats'].replace(0, np.nan, inplace=True)
# Посмотрим на статистику
print(df['seats'].describe())
# Создание гистограммы
histogram = go.Histogram(x=df['seats'], nbinsx=100)
# Создание макета графика
layout = go.Layout(
title={
'text':'Распределение количества посадочных мест',
'x': 0.5, # Расположение заголовка (0: левый край, 0.5: центр, 1: правый край)
'xanchor': 'center',
'yanchor': 'middle'
},
xaxis=dict(title='Количество мест'),
yaxis=dict(title='Частота')
)
# Создание фигуры
fig = go.Figure(data=[histogram], layout=layout)
# Отображение графика
fig.show()
count 4659.000000 mean 111.586607 std 123.188197 min 1.000000 25% 40.000000 50% 79.000000 75% 143.000000 max 1288.000000 Name: seats, dtype: float64
# Посчитаем 99й персентиль и выведем заведения, число мест в которых его превышает
seats_99_pct = df['seats'].quantile(0.99)
print(seats_99_pct)
places_99_pct = df.query('seats > @seats_99_pct',engine='python').sort_values(by='seats', ascending=False)
display(places_99_pct[['name','category','address','seats']])
625.0
| name | category | address | seats | |
|---|---|---|---|---|
| 6641 | One Price Coffee | кофейня | Москва, проспект Вернадского, 84, стр. 1 | 1288.0 |
| 6684 | Пивной Ресторан | бар,паб | Москва, проспект Вернадского, 121, корп. 1 | 1288.0 |
| 6524 | Ян Примус | ресторан | Москва, проспект Вернадского, 121, корп. 1 | 1288.0 |
| 6838 | Alternative Coffee | кофейня | Москва, проспект Вернадского, 41, стр. 1 | 1288.0 |
| 6808 | Яндекс Лавка | ресторан | Москва, проспект Вернадского, 51, стр. 1 | 1288.0 |
| 6518 | Delonixcafe | ресторан | Москва, проспект Вернадского, 94, корп. 1 | 1288.0 |
| 6658 | Гудбар | бар,паб | Москва, проспект Вернадского, 97, корп. 1 | 1288.0 |
| 6574 | Мюнгер | пиццерия | Москва, проспект Вернадского, 97, корп. 1 | 1288.0 |
| 6807 | Loft-Cafe Академия | кафе | Москва, проспект Вернадского, 84, стр. 1 | 1288.0 |
| 6771 | Точка | кафе | Москва, проспект Вернадского, 84, стр. 1 | 1288.0 |
| 6690 | Японская Кухня | ресторан | Москва, проспект Вернадского, 121, корп. 1 | 1288.0 |
| 4231 | Рестобар Argomento | столовая | Москва, Кутузовский проспект, 41, стр. 1 | 1200.0 |
| 2722 | Маргарита | быстрое питание | Москва, Измайловское шоссе, 71, корп. А | 1040.0 |
| 2713 | Ваня И Гоги | бар,паб | Москва, Измайловское шоссе, 71, корп. А | 1040.0 |
| 2966 | Матрешка | кафе | Москва, Измайловское шоссе, 71, корп. А | 1040.0 |
| 2770 | Шоколадница | кофейня | Москва, Измайловское шоссе, 71, корп. А | 1040.0 |
| 4245 | Стейк & Бургер | кафе | Москва, Киевская улица, 2 | 920.0 |
| 4180 | Eataly | бар,паб | Москва, Киевская улица, 2 | 920.0 |
| 5486 | Дом | кафе | Москва, улица Юности, 1 | 760.0 |
| 7987 | Ресторан | ресторан | Москва, улица Маршала Захарова, 6, корп. 1 | 675.0 |
| 2913 | Хаус Бар | бар,паб | Москва, Измайловское шоссе, 71, корп. 2Б | 660.0 |
| 2901 | Ресторан Тройка | бар,паб | Москва, Измайловское шоссе, 71, корп. 2Б | 660.0 |
| 5835 | Lyanson’S Coffee | кафе | Москва, Мичуринский проспект, 27, корп. 1 | 650.0 |
| 5758 | Шоколадница | кофейня | Москва, Мичуринский проспект, 22, корп. 1 | 650.0 |
| 5738 | Университетское | кофейня | Москва, Мичуринский проспект, 8, стр. 1 | 650.0 |
| 5720 | Ресторан Китайской Кухни Чуаньюй | ресторан | Москва, Мичуринский проспект, 7, корп. 1 | 650.0 |
| 5655 | The Fox Pub | бар,паб | Москва, Мичуринский проспект, 22, корп. 1 | 650.0 |
| 5841 | For Your Kids | кафе | Москва, Мичуринский проспект, 58, корп. 1 | 650.0 |
| 6548 | Vibes Cafe | кафе | Москва, улица Миклухо-Маклая, 6 | 644.0 |
| 6696 | Кабул | ресторан | Москва, улица Миклухо-Маклая, 6 | 644.0 |
...рекомендуемая площадь на одно посадочное место для кафе составляет от 1,2 до 1,5 квадратных метров, а для ресторана - от 1,5 до 2,5 квадратных метров. Например, если вы планируете открыть кафе на 50 посадочных мест, то общая площадь заведения должна составлять от 60 до 75 квадратных метров
Это значит, что площадь, к примеру, кофейни One Price Coffee должна составлять 1288 мест * 1,2 м = 1545 м. При примерной арендной стоимости в Москве 1 м. кв = 3000 р. в месяц, арендная плата будет составлять 4.635 млн. руб. Для того, чтобы отбивать какую аренду, One Price Coffee должна торговать преимущественно кокаином 😊😊😊😊.
Короче говоря, данные о количестве посадочных мест в этих записях выглядят, как ошибки. Кроме того, можно обратить внимание, что цифры повторяются у заведений, расположенных на одной улице: пр. Вернадского, Измайловское шоссе, Мичуринский проспект.
У нас есть колонка с названием улиц, выделенных из адреса, проверим, много ли таких предположительных ошибок.
# сгруппируем данные по улице и количеству мест
grouped_df = df.groupby(['street', 'seats']).size().reset_index(name='count').sort_values(by='seats', ascending=False)
display(grouped_df.head(20))
| street | seats | count | |
|---|---|---|---|
| 1614 | проспект Вернадского | 1288.0 | 11 |
| 697 | Кутузовский проспект | 1200.0 | 1 |
| 549 | Измайловское шоссе | 1040.0 | 4 |
| 593 | Киевская улица | 920.0 | 2 |
| 2218 | улица Юности | 760.0 | 1 |
| 1938 | улица Маршала Захарова | 675.0 | 1 |
| 548 | Измайловское шоссе | 660.0 | 2 |
| 866 | Мичуринский проспект | 650.0 | 6 |
| 1976 | улица Миклухо-Маклая | 644.0 | 2 |
| 726 | Ленинградский проспект | 625.0 | 23 |
| 1788 | улица Гарибальди | 600.0 | 1 |
| 2140 | улица Сталеваров | 585.0 | 2 |
| 1535 | Ярославское шоссе | 500.0 | 3 |
| 442 | Грайвороновская улица | 500.0 | 1 |
| 774 | Лесная улица | 500.0 | 16 |
| 1293 | Старая Басманная улица | 500.0 | 1 |
| 764 | Ленинский проспект | 495.0 | 6 |
| 1752 | улица Вавилова | 491.0 | 2 |
| 1023 | Олимпийский проспект | 481.0 | 2 |
| 1687 | улица Арбат | 480.0 | 13 |
В наших данных значительное количество записей с одинаковой комбинацией "улица-посадочных мест" (другими словами, разных заведений, находящихся на одной улице и имеющих одинаковое количество посадочных мест.
В каких-то случаях, например, 11 заведений на проспекте Вернадского с 1288 посадочными местами, мы можем утверждать, что это ошибки. В других - например, 23 заведения с 625 местами на Ленинградском проспекте - я бы не был столь уверен в ошибке... Ленинградский проспект большой, ресторан на 625 посадочных мест в принципе возможно.
Проверим конкретно эту комбинацию и примем решение.
df.query('street=="Ленинградский проспект" & seats==625', engine='python')
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | category_numeric | district_numeric | street | is_24_7 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1326 | Нам | бар,паб | Москва, Ленинградский проспект, 69, стр. 1 | Северный административный округ | ежедневно, 11:00–21:00 | 55.803236 | 37.517898 | 4.2 | NaN | NaN | NaN | NaN | True | 625.0 | 5 | 1 | Ленинградский проспект | False |
| 1351 | Максима Пицца | пиццерия | Москва, Ленинградский проспект, 78, корп. 1 | Северный административный округ | пн-пт 09:00–00:00; сб,вс 10:00–00:00 | 55.805807 | 37.513994 | 4.3 | выше среднего | Средний счёт:1500–1800 ₽ | 1650.0 | NaN | False | 625.0 | 4 | 1 | Ленинградский проспект | False |
| 1355 | Страдивари | ресторан | Москва, Ленинградский проспект, 77, корп. 1 | Северный административный округ | ежедневно, 11:00–23:00 | 55.804980 | 37.509454 | 4.2 | выше среднего | Средний счёт:1000–2000 ₽ | 1500.0 | NaN | True | 625.0 | 2 | 1 | Ленинградский проспект | False |
| 1367 | Академия | пиццерия | Москва, Ленинградский проспект, 72, корп. 1 | Северный административный округ | пн-пт 09:00–23:00; сб,вс 11:00–23:00 | 55.805579 | 37.520086 | 4.2 | средние | Средний счёт:1000–1200 ₽ | 1100.0 | NaN | True | 625.0 | 4 | 1 | Ленинградский проспект | False |
| 1380 | Север-Метрополь | кофейня | Москва, Ленинградский проспект, 75, корп. 1 | Северный административный округ | ежедневно, 09:00–21:00 | 55.804940 | 37.512423 | 4.6 | NaN | NaN | NaN | NaN | True | 625.0 | 3 | 1 | Ленинградский проспект | False |
| 1385 | Чайхона На Соколе | кафе | Москва, Ленинградский проспект, 69, стр. 1 | Северный административный округ | ежедневно, 08:00–23:00 | 55.803236 | 37.517931 | 4.3 | средние | Средний счёт:400–800 ₽ | 600.0 | NaN | False | 625.0 | 1 | 1 | Ленинградский проспект | False |
| 1393 | Кулинарная Лавка Братьев Караваевых | кафе | Москва, Ленинградский проспект, 72, корп. 1 | Северный административный округ | ежедневно, 08:00–23:00 | 55.805791 | 37.520339 | 4.3 | средние | Средний счёт:500–800 ₽ | 650.0 | NaN | True | 625.0 | 1 | 1 | Ленинградский проспект | False |
| 1442 | Abc Coffee Roasters | кофейня | Москва, Ленинградский проспект, 72, корп. 1 | Северный административный округ | пн-пт 08:00–22:00; сб,вс 10:00–22:00 | 55.805547 | 37.520395 | 4.5 | средние | Цена чашки капучино:220–270 ₽ | NaN | 245.0 | True | 625.0 | 3 | 1 | Ленинградский проспект | False |
| 1446 | 9 Bar Coffee | кофейня | Москва, Ленинградский проспект, 80, корп. 1 | Северный административный округ | пн-пт 07:30–21:00; сб,вс 07:30–19:00 | 55.810352 | 37.506625 | 4.3 | NaN | Цена чашки капучино:60–150 ₽ | NaN | 105.0 | True | 625.0 | 3 | 1 | Ленинградский проспект | False |
| 1464 | Находка | ресторан | Москва, Ленинградский проспект, 74, корп. 1 | Северный административный округ | ежедневно, 09:00–22:00 | 55.804972 | 37.517319 | 4.9 | NaN | NaN | NaN | NaN | True | 625.0 | 2 | 1 | Ленинградский проспект | False |
| 1475 | Falko Pizza | пиццерия | Москва, Ленинградский проспект, 80, корп. 1 | Северный административный округ | пн-сб 09:30–20:30 | 55.810617 | 37.508490 | 4.3 | средние | Средний счёт:500–1500 ₽ | 1000.0 | NaN | False | 625.0 | 4 | 1 | Ленинградский проспект | False |
| 1498 | Brooklyn Coffee | кофейня | Москва, Ленинградский проспект, 78, корп. 1 | Северный административный округ | пн-пт 07:00–21:00; сб,вс 09:00–20:00 | 55.805743 | 37.514283 | 4.0 | NaN | Цена чашки капучино:60–150 ₽ | NaN | 105.0 | False | 625.0 | 3 | 1 | Ленинградский проспект | False |
| 1548 | French Bakery | кафе | Москва, Ленинградский проспект, 74, корп. 1 | Северный административный округ | пн-пт 07:30–23:00; сб 08:00–22:00; вс 09:00–21:00 | 55.805133 | 37.516952 | 3.4 | низкие | NaN | NaN | NaN | True | 625.0 | 1 | 1 | Ленинградский проспект | False |
| 1672 | Ача-Чача | ресторан | Москва, Ленинградский проспект, 9Б, стр. 1 | Северный административный округ | ежедневно, 12:00–00:00 | 55.780576 | 37.574727 | 4.6 | высокие | Средний счёт:1500–2000 ₽ | 1750.0 | NaN | True | 625.0 | 2 | 1 | Ленинградский проспект | False |
| 1766 | Золотая Бухара | ресторан | Москва, Ленинградский проспект, 48, подъезд 1 | Северный административный округ | ежедневно, 12:00–23:00 | 55.796294 | 37.542917 | 4.4 | NaN | NaN | NaN | NaN | False | 625.0 | 2 | 1 | Ленинградский проспект | False |
| 1826 | Волконский | булочная | Москва, Ленинградский проспект, 29, корп. 1 | Северный административный округ | ежедневно, 08:00–22:00 | 55.785088 | 37.562647 | 4.4 | NaN | NaN | NaN | NaN | True | 625.0 | 7 | 1 | Ленинградский проспект | False |
| 1838 | Take And Wake | кофейня | Москва, Ленинградский проспект, 31А, стр. 1 | Северный административный округ | пн-пт 08:00–20:00; сб,вс 09:00–19:00 | 55.783494 | 37.559933 | 4.5 | низкие | Цена чашки капучино:120–190 ₽ | NaN | 155.0 | True | 625.0 | 3 | 1 | Ленинградский проспект | False |
| 1867 | Vasilchukí Chaihona №1 | ресторан | Москва, Ленинградский проспект, 31А, стр. 1 | Северный административный округ | пн-чт 11:00–00:00; пт,сб 11:00–01:00; вс 11:00... | 55.783590 | 37.560170 | 4.3 | выше среднего | Средний счёт:от 1500 ₽ | 1500.0 | NaN | True | 625.0 | 2 | 1 | Ленинградский проспект | False |
| 1880 | Чайхона Айва | кафе | Москва, Ленинградский проспект, 45, корп. 1 | Северный административный округ | ежедневно, круглосуточно | 55.798882 | 37.533990 | 4.3 | выше среднего | NaN | NaN | NaN | True | 625.0 | 1 | 1 | Ленинградский проспект | True |
| 2010 | Coffeeteabar | кофейня | Москва, Ленинградский проспект, 60, корп. 1 | Северный административный округ | пн-пт 07:30–20:30; сб 07:30–19:30; вс 08:30–19:30 | 55.799751 | 37.535100 | 4.4 | низкие | Цена чашки капучино:150–210 ₽ | NaN | 180.0 | False | 625.0 | 3 | 1 | Ленинградский проспект | False |
| 2035 | Столичный Вкус | столовая | Москва, Ленинградский проспект, 35, стр. 1 | Северный административный округ | пн-пт 09:00–17:00 | 55.789750 | 37.552723 | 4.1 | средние | NaN | NaN | NaN | True | 625.0 | 8 | 1 | Ленинградский проспект | False |
| 2087 | Лобби-Бар Манжо | бар,паб | Москва, Ленинградский проспект, 31А, стр. 1 | Северный административный округ | пн-пт 08:30–20:00 | 55.783622 | 37.559943 | 4.0 | средние | Средний счёт:500 ₽ | 500.0 | NaN | False | 625.0 | 5 | 1 | Ленинградский проспект | False |
| 2104 | Ванильное Небо | кофейня | Москва, Ленинградский проспект, 31А, стр. 1 | Северный административный округ | пн-пт 08:00–20:00; сб 09:00–18:00 | 55.784066 | 37.559398 | 3.7 | средние | Средний счёт:400 ₽ | 400.0 | NaN | True | 625.0 | 3 | 1 | Ленинградский проспект | False |
Можем посчитать записи, в которых количество мест >= 625 (0.99 квантиль) и удалить их, тем более, что заказчики планируют открывать кофейню, а не ресторан на 1000 мест.
print(
'Записей с количеством посадочных мест >= 625: ',
df.query('seats>=625', engine='python').shape[0],
', что составляет ',
round(df.query('seats>=625', engine='python').shape[0] * 100 / df.shape[0], 2),
' % от общего количества записей в базе'
)
print('\n')
print(df.info())
Записей с количеством посадочных мест >= 625: 53 , что составляет 0.63 % от общего количества записей в базе <class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 18 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 4710 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null boolean 13 seats 4659 non-null float64 14 category_numeric 8406 non-null int64 15 district_numeric 8406 non-null int64 16 street 8406 non-null object 17 is_24_7 8406 non-null bool dtypes: bool(1), boolean(1), float64(6), int64(2), object(8) memory usage: 1.1+ MB None
# удалим записи и выведем новую гистограмму распределения
df = df.query('seats < 625 | seats.isna()', engine='python').reset_index()
# Посморим на статистику
print(df['seats'].describe())
# Создание гистограммы
histogram = go.Histogram(x=df['seats'], nbinsx=100)
# Создание макета графика
layout = go.Layout(
title={
'text':'Распределение количества посадочных мест',
'x': 0.5, # Расположение заголовка (0: левый край, 0.5: центр, 1: правый край)
'xanchor': 'center',
'yanchor': 'middle'
},
xaxis=dict(title='Количество мест'),
yaxis=dict(title='Частота')
)
# Создание фигуры
fig = go.Figure(data=[histogram], layout=layout)
# Отображение графика
fig.show()
count 4606.000000 mean 103.386018 std 92.622193 min 1.000000 25% 40.000000 50% 76.000000 75% 140.000000 max 600.000000 Name: seats, dtype: float64
# пройдем в цикле по уникальным названиям категорий и построим для каждой гистограмму распределения мест
categories = df['category'].unique()
fig = go.Figure()
for category in categories:
fig.add_trace(go.Box(
y=df[df['category'] == category]['seats'],
name=category
))
# Обновление макета
fig.update_layout(
title='Распределение количества посадочных мест по категориям',
xaxis=dict(title='Категория'),
yaxis=dict(title='Количество мест'),
title_x=0.5 # Центрирование заголовка
)
# Показать график
fig.show()
Например, 99% кофеен в Москве имеют до 300 посадочных мест, при этом самое распространенное количество посадочных мест в кофейне - 80.
Что можно заметить:
Выведем для наглядности график средних значений всех категорий
medians = df.groupby('category').agg(median=('seats', 'median')).reset_index().sort_values(by='median', ascending=False)
display(medians)
# Создание объекта Bar для построения барплота
barplot = go.Bar(
x=medians['category'], # Категории по x
y=medians['median'], # Медиана по y
marker_color='lightskyblue' # Цвет столбцов
)
# Создание объекта Figure и добавление барплота
fig = go.Figure(barplot)
# Обновление макета
fig.update_layout(
title='Медианное количество посадочных мест по категориям',
xaxis=dict(title='Категория'),
yaxis=dict(title='Медианное количество мест'),
title_x=0.5
)
# Показать график
fig.show()
| category | median | |
|---|---|---|
| 6 | ресторан | 87.5 |
| 0 | бар,паб | 80.0 |
| 4 | кофейня | 80.0 |
| 7 | столовая | 76.0 |
| 2 | быстрое питание | 75.0 |
| 3 | кафе | 60.0 |
| 5 | пиццерия | 55.0 |
| 1 | булочная | 51.0 |
chain = df.query('chain == True', engine='python').shape[0]
single = df.query('chain == False', engine='python').shape[0]
brands = df.query('chain == True', engine='python')['name'].nunique()
print('Сетевых точек: ', chain, ', одиночных точек: ', single, ', а сетей всего - ', brands)
Сетевых точек: 3156 , одиночных точек: 5197 , а сетей всего - 688
values = [chain, single]
labels = ['Сетевых точек', 'Oдиночных точек']
# Создание объекта Pie для круговой диаграммы
pie_chart = go.Pie(
labels=labels, # Метки для секторов
values=values, # Значения для секторов
hole=0.3, # Размер центрального отверстия (0 - полная круговая диаграмма)
showlegend=False, # не показываем легенду
textinfo='percent+label', # Отображаем проценты и метки
marker=dict(colors=['lightskyblue', 'lightgreen'])
)
# Создание объекта Layout с заголовком
layout = go.Layout(
title='Распределение количества точек сетевых и одиночных заведений в Москве',
title_x=0.5
)
# Создание объекта Figure с круговой диаграммой и макетом
fig = go.Figure(data=[pie_chart], layout=layout)
# Показать диаграмму
fig.show()
# посчитаем количество точек по категориям и количество сетевых
chains_by_category = df.query('chain==True', engine='python').groupby('category').agg(chain_outlets=('address','count')).reset_index()
total_outlets = df.groupby('category').agg(chain_outlets=('address','count')).reset_index()
# Объединение двух DataFrame по категориям
chains_by_category = (chains_by_category.merge(total_outlets, how='inner', on='category')
.sort_values(by='chain_outlets_y', ascending=False).reset_index(drop=True)
)
# Переименование столбцов
chains_by_category.columns = ['category', 'chain_outlets', 'total_outlets']
chains_by_category['share'] = round(chains_by_category['chain_outlets'] * 100 / chains_by_category['total_outlets'], 2)
display(chains_by_category)
| category | chain_outlets | total_outlets | share | |
|---|---|---|---|---|
| 0 | кафе | 755 | 2366 | 31.91 |
| 1 | ресторан | 726 | 2031 | 35.75 |
| 2 | кофейня | 713 | 1401 | 50.89 |
| 3 | бар,паб | 164 | 756 | 21.69 |
| 4 | пиццерия | 324 | 629 | 51.51 |
| 5 | быстрое питание | 234 | 602 | 38.87 |
| 6 | столовая | 85 | 313 | 27.16 |
| 7 | булочная | 155 | 255 | 60.78 |
# Создание столбчатой диаграммы
fig = go.Figure()
# Добавление столбцов для total_outlets
fig.add_trace(go.Bar(
x=chains_by_category['category'],
y=chains_by_category['total_outlets'],
orientation='v',
marker_color='lightskyblue',
name='Общее количество точек'
))
# Добавление столбцов для chain_outlets
fig.add_trace(go.Bar(
x=chains_by_category['category'],
y=chains_by_category['chain_outlets'],
orientation='v',
marker_color='orange',
name='Из них - сетевых',
text=chains_by_category['share'].astype(str) + '%', # Добавляем проценты к тексту над столбцами
textposition='outside' # Размещаем текст снаружи столбца
))
# Обновление макета
fig.update_layout(
title='Количество точек по категориям: соотношение сетевых точек и общего их числа',
title_x=0.5,
xaxis=dict(title='Категория'),
yaxis=dict(title='Количество точек'),
barmode='group', # Группировка столбцов
legend=dict(x=1, y=1) # Размещаем легенду справа от графика
)
# Показать график
fig.show()
В категории "Кофейня" 50.89% точек - сетевые.
top_15 = (df.query('chain == True', engine='python').groupby('name')
.agg(
outlets=('address','count'),
category=('category','first')
)
.sort_values(by='outlets', ascending=False)
.reset_index()
)
top_15 = top_15.head(15)
display(top_15)
| name | outlets | category | |
|---|---|---|---|
| 0 | Шоколадница | 118 | кофейня |
| 1 | Домино'С Пицца | 77 | пиццерия |
| 2 | Додо Пицца | 74 | пиццерия |
| 3 | One Price Coffee | 71 | кофейня |
| 4 | Яндекс Лавка | 68 | ресторан |
| 5 | Cofix | 65 | кофейня |
| 6 | Prime | 50 | ресторан |
| 7 | Хинкальная | 44 | быстрое питание |
| 8 | Кофепорт | 42 | кофейня |
| 9 | Кулинарная Лавка Братьев Караваевых | 38 | кафе |
| 10 | Теремок | 38 | ресторан |
| 11 | Чайхана | 37 | кафе |
| 12 | Буханка | 32 | булочная |
| 13 | Cofefest | 32 | кофейня |
| 14 | Му-Му | 27 | кафе |
import plotly.express as px
# Группировка данных по категориям и вычисление суммы точек в каждой категории
category_totals = top_15.groupby('category')['outlets'].sum().sort_values(ascending=False)
# Определение порядка категорий
category_order = category_totals.index
# Сортировка категорий в DataFrame top_15 в обратном порядке
category_order_reversed = category_order[::-1]
# Сортировка категорий в DataFrame top_15 в соответствии с порядком category_order_reversed
top_15_sorted = top_15.set_index('category').loc[category_order_reversed].reset_index()
# Сортировка по 'outlets' внутри каждой 'category', сохраняя порядок категорий
sorted_df = top_15_sorted.groupby('category', sort=False).apply(lambda x: x.sort_values(by='outlets')).reset_index(drop=True)
# Создание графика
fig = px.bar(sorted_df,
x='outlets', # Ось x - количество точек
y='name', # Ось y - названия заведений
color='category', # Кодирование цвета по типу заведения
orientation='h', # Горизонтальное расположение столбцов
title='Топ-15 сетевых заведений по количеству точек в Москве',
labels={'outlets': 'Количество точек', 'name': '', 'category': ''},
)
# Обновление макета для центрирования заголовка
fig.update_layout(title_x=0.5)
# Показать график
fig.show()
# Рассчитаем средние рейтинги для кадой категории заведений.
# Поскольку шкала рейтингов фиксирована 0-5 и не может иметь выбросов, будем использовать среднее
avg_rating = (
df.groupby('category')
.agg(avg_rating=('rating','mean'))
.sort_values(by='avg_rating', ascending=True)
.reset_index()
)
display(avg_rating)
# Создание графика
fig = px.bar(avg_rating,
x='avg_rating', # Ось x - значение среднего рейтинга
y='category', # Ось y - названия категорий
orientation='h', # Горизонтальное расположение столбцов
title='Средний рейтинг заведений по категориям',
labels={'avg_rating': 'Средний рейтинг', 'category':''},
color_discrete_sequence=['Teal'] # Установите цвет столбцов
)
# Обновление макета для центрирования заголовка и ограничения диапазона оси X
fig.update_layout(
title_x=0.5,
xaxis=dict(range=[4, 4.4]) # Установите диапазон оси X здесь
)
# Показать график
fig.show()
| category | avg_rating | |
|---|---|---|
| 0 | быстрое питание | 4.049834 |
| 1 | кафе | 4.124007 |
| 2 | столовая | 4.211821 |
| 3 | булочная | 4.267843 |
| 4 | кофейня | 4.277373 |
| 5 | ресторан | 4.289808 |
| 6 | пиццерия | 4.300636 |
| 7 | бар,паб | 4.390079 |
бары, пабы, 4.39. Аутсайдер - быстрое питание, 4.05# подготовим датафрейм со средними значениями рейтингов заветений по районам
avg_rating_district = ( df.groupby('district')
.agg(district_rating=('rating','mean'))
.reset_index()
)
display(avg_rating_district)
| district | district_rating | |
|---|---|---|
| 0 | Восточный административный округ | 4.174589 |
| 1 | Западный административный округ | 4.179904 |
| 2 | Северный административный округ | 4.238997 |
| 3 | Северо-Восточный административный округ | 4.148260 |
| 4 | Северо-Западный административный округ | 4.208802 |
| 5 | Центральный административный округ | 4.377520 |
| 6 | Юго-Восточный административный округ | 4.101120 |
| 7 | Юго-Западный административный округ | 4.172419 |
| 8 | Южный административный округ | 4.184063 |
# Преобразуем данные о районе и среднем рейтинге в словарь для быстрого поиска
rating_dict = dict(zip(avg_rating_district['district'], avg_rating_district['district_rating']))
moscow_lat, moscow_lng = 55.751244, 37.618423
state_geo_url = 'https://code.s3.yandex.net/data-analyst/admin_level_geomap.geojson'
response = requests.get(state_geo_url)
state_geo = response.json()
for feature in state_geo['features']:
district_name = feature['properties']['name']
feature['properties']['rating'] = rating_dict.get(district_name, 'Нет данных')
m = folium.Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# сначала создаем FeatureGroup для хороплета и данных JSON
districts = FeatureGroup(name='Районы и средний рейтинг', overlay=True, control=True, show=True)
folium.Choropleth(
geo_data=state_geo,
name="choropleth",
data=avg_rating_district,
columns=["district", "district_rating"],
key_on="feature.properties.name", # уточнение пути к полю, по котторому хороплет сопоставляет гео данные и данные из ДФ
fill_color="YlOrRd",
fill_opacity=0.5,
line_opacity=0.1,
# legend_name="Средний чек в заведениях района Москвы", - не отображается вместе с цветовой шкалой, ее делаем отдельно ниже
).geojson.add_to(districts) # важно поместить хороплет в FeatureGroup через .geojson, иначе не работает
# выводим данные о названии района и среднем чеке в поп-апе
folium.GeoJson(
state_geo,
style_function=lambda feature: {
'fillColor': 'transparent',
'color': 'transparent',
'weight': 0
},
name="geojson",
tooltip=folium.features.GeoJsonTooltip(
fields=['name', 'rating'],
aliases=['Район: ', 'Средний рейтинг: '],
localize=True
),
popup=folium.GeoJsonPopup(
fields=['name', 'rating'],
aliases=['Район: ', 'Средний рейтинг: '],
localize=True
)
).add_to(districts) # помещаем в тот же слой для управления
# добавляем слой с районами и подписями на карту
districts.add_to(m)
# Добавляем элемент управления слоями и указываем позицию
folium.LayerControl(position='topleft').add_to(m)
folium.TileLayer('Cartodb Positron',overlay=False, show=False, control = False).add_to(m)
# вариант добавления градиентной шкалы вручную - она не отображется, если хороплет добавлять в слой через geojson
colormap = linear.YlOrRd_09.scale(avg_rating_district['district_rating'].min(), avg_rating_district['district_rating'].max())
colormap = colormap.to_step(n=10)
colormap.caption = 'Средний рейтинг заведений по районам'
colormap.add_to(m)
# Создание HTML заголовка, в Folium не нашел готовый арибут title, как на графиках
# еще бы здорово было убрать вертикальную полосу прокрутки, сдвинуть заголовок вниз на карту и шкалу ниже опустить,
# но я потратил 2 дня на поиски решения без особого успеха
title_html = '''
<div style="position: relative; width: 100%; background-color: transparent; z-index: 1000; text-align: center;">
<h3 style="margin: 0; padding: 10px; font: calibri, font-size: 20px;">
<b>Средний рейтинг заведений по районам Москвы</b></h3>
</div>
'''
# Добавление заголовка на карту
m.get_root().html.add_child(folium.Element(title_html))
# выводим карту
m
Заведения будут организованы в кластеры для удобства просмотра, при нажатии на значок заведения появится всплывающее окно с его названием, рейтингом и категорией
# moscow_lat - широта центра Москвы, moscow_lng - долгота центра Москвы
moscow_lat, moscow_lng = 55.751244, 37.618423
# создаём карту Москвы
m = Map(location=[moscow_lat, moscow_lng], zoom_start=10, tiles="Cartodb Positron")
# создаём пустой кластер, добавляем его на карту
marker_cluster = MarkerCluster().add_to(m)
# пишем функцию, которая принимает строку датафрейма,
# создаёт маркер в текущей точке и добавляет его в кластер marker_cluster
def create_clusters(row):
# Создаем HTML-контент для pop-up
popup_html = f"""
<b>{row['name']}</b><br>
{row['rating']} ★<br>
{row['category']}
"""
# Создаем и добавляем маркер к кластеру
Marker(
[row['lat'], row['lng']],
popup=folium.Popup(popup_html, max_width=300)
).add_to(marker_cluster)
# применяем функцию create_clusters() к каждой строке датафрейма
df.apply(create_clusters, axis=1)
# Создание HTML заголовка, в Folium не нашел готовый метод, как на графиках
title_html = '''
<div style="position: relative; width: 100%; background-color: transparent; z-index: 1000; text-align: center;">
<h3 style="margin: 0; padding: 10px; font: calibri, font-size: 20px;">
<b>Расположение всех заведений из датасета</b></h3>
</div>
'''
# Добавление заголовка на карту
m.get_root().html.add_child(folium.Element(title_html))
# выводим карту
m
Определим топ-15 улиц с самым большим количеством точек и посмотрим на распределение их категорий на графике
# отбираем топ-15 улиц по количеству заведений
top_streets = (df.groupby('street')
.agg(count = ('name','count'))
.sort_values(by='count', ascending=False)
.reset_index().head(15)
)
display(top_streets)
# Подсчет количества заведений каждой категории на каждой из этих топ-15 улиц
categories_by_street_list = []
for street in top_streets['street']:
row = (df.query('street == @street', engine='python')
.groupby('category')
.agg(c_count=('name', 'count'))
.reset_index())
row['street'] = street # Добавляем информацию об улице
categories_by_street_list.append(row)
categories_by_street = pd.concat(categories_by_street_list).reset_index(drop=True)
# переводим в широкий формат
categories_by_street = categories_by_street.pivot_table(index='street', columns='category', values='c_count').reset_index()
display(categories_by_street)
| street | count | |
|---|---|---|
| 0 | проспект Мира | 184 |
| 1 | Профсоюзная улица | 122 |
| 2 | Ленинский проспект | 107 |
| 3 | проспект Вернадского | 97 |
| 4 | Дмитровское шоссе | 88 |
| 5 | Каширское шоссе | 77 |
| 6 | Варшавское шоссе | 76 |
| 7 | Ленинградский проспект | 72 |
| 8 | Ленинградское шоссе | 70 |
| 9 | МКАД | 65 |
| 10 | Люблинская улица | 60 |
| 11 | улица Вавилова | 55 |
| 12 | Кутузовский проспект | 53 |
| 13 | Пятницкая улица | 48 |
| 14 | улица Миклухо-Маклая | 47 |
| category | street | бар,паб | булочная | быстрое питание | кафе | кофейня | пиццерия | ресторан | столовая |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Варшавское шоссе | 6.0 | NaN | 7.0 | 18.0 | 14.0 | 4.0 | 20.0 | 7.0 |
| 1 | Дмитровское шоссе | 6.0 | 2.0 | 10.0 | 23.0 | 11.0 | 8.0 | 24.0 | 4.0 |
| 2 | Каширское шоссе | 2.0 | NaN | 10.0 | 20.0 | 16.0 | 5.0 | 19.0 | 5.0 |
| 3 | Кутузовский проспект | 2.0 | 1.0 | 2.0 | 14.0 | 13.0 | 3.0 | 16.0 | 2.0 |
| 4 | Ленинградский проспект | 13.0 | 3.0 | 2.0 | 8.0 | 18.0 | 6.0 | 20.0 | 2.0 |
| 5 | Ленинградское шоссе | 5.0 | 2.0 | 5.0 | 13.0 | 13.0 | 3.0 | 26.0 | 3.0 |
| 6 | Ленинский проспект | 10.0 | 3.0 | 2.0 | 26.0 | 23.0 | 5.0 | 33.0 | 5.0 |
| 7 | Люблинская улица | 5.0 | NaN | 5.0 | 26.0 | 11.0 | 1.0 | 10.0 | 2.0 |
| 8 | МКАД | 1.0 | NaN | 9.0 | 45.0 | 4.0 | NaN | 5.0 | 1.0 |
| 9 | Профсоюзная улица | 6.0 | 4.0 | 15.0 | 35.0 | 18.0 | 15.0 | 26.0 | 3.0 |
| 10 | Пятницкая улица | 9.0 | 3.0 | 2.0 | 7.0 | 6.0 | 3.0 | 18.0 | NaN |
| 11 | проспект Вернадского | 5.0 | 1.0 | 12.0 | 23.0 | 14.0 | 11.0 | 29.0 | 2.0 |
| 12 | проспект Мира | 12.0 | 4.0 | 21.0 | 53.0 | 36.0 | 11.0 | 45.0 | 2.0 |
| 13 | улица Вавилова | 2.0 | 2.0 | 11.0 | 15.0 | 10.0 | 3.0 | 12.0 | NaN |
| 14 | улица Миклухо-Маклая | 3.0 | NaN | 4.0 | 20.0 | 4.0 | 2.0 | 14.0 | NaN |
# Заполнение пропущенных значений нулями
categories_by_street = categories_by_street.fillna(0)
# Вычисление суммы заведений для каждой улицы
categories_by_street['total'] = categories_by_street.iloc[:, 1:].sum(axis=1)
# Сортировка по убыванию суммы заведений
categories_by_street = categories_by_street.sort_values(by='total', ascending=True)
# Удаление столбца 'total'
categories_by_street = categories_by_street.drop(columns=['total'])
# Преобразование данных для Plotly в длинный формат
df_melted = categories_by_street.melt(id_vars='street', var_name='category', value_name='count').fillna(0)
# Создание графика
fig = px.bar(df_melted,
x='count',
y='street',
color='category',
orientation='h',
title='Количество заведений по категориям на топ-15 улицах',
labels={'count': 'Количество заведений', 'street': '', 'category': 'Категория'}
)
# Обновление макета для центрирования заголовка
fig.update_layout(title_x=0.5)
# Показать график
fig.show()
# отбираем улицы по количеству заведений в обратном порядке
low_streets = (df.groupby('street')
.agg(count = ('name','count'))
.sort_values(by='count', ascending=True)
.reset_index()
)
# оставим только улицы с 1 заведением
low_streets = low_streets.query('count==1', engine='python').reset_index(drop=True)
print('Количество улиц с единственным на них заведением: ', low_streets.shape[0])
# возьмем из базы информацю об этих заведениях
df_low = df.loc[df['street'].isin(low_streets['street'])].reset_index(drop=True)
df_low.drop(columns='index', inplace=True)
# и посмотрим на закономерности в разных разрезах
df_low_district = df_low.groupby('district').agg(count =('name','count')).sort_values(by='count', ascending=False).reset_index()
display("В каких районах эти улицы", df_low_district)
df_low_cat = df_low.groupby('category').agg(count =('name','count')).sort_values(by='count', ascending=False).reset_index()
display("Какие категории заведений на этих улицы", df_low_cat)
df_low_is_24_7 = df_low.groupby('is_24_7').agg(count =('name','count')).sort_values(by='count', ascending=False).reset_index()
display("Много ли на них заведений, работающих 24/7", df_low_is_24_7)
df_low_chain = df_low.groupby('chain').agg(count =('name','count')).sort_values(by='count', ascending=False).reset_index()
display("Много ли на них сетевых заведений", df_low_chain)
df_low_rating = df_low['rating'].mean()
display('Средний рейтинг "одиноких" заведений: - ', df_low_rating)
df_low_price = df_low.groupby('price').agg(count =('name','count')).sort_values(by='count', ascending=False).reset_index()
display("Какие ценовые категории заведений там представлены", df_low_price)
df_low_seats = df_low['seats'].mean()
display('Среднее количество мест в "одиноких" заведенях: - ', df_low_seats)
Количество улиц с единственным на них заведением: 458
'В каких районах эти улицы'
| district | count | |
|---|---|---|
| 0 | Центральный административный округ | 145 |
| 1 | Северо-Восточный административный округ | 55 |
| 2 | Восточный административный округ | 52 |
| 3 | Северный административный округ | 52 |
| 4 | Южный административный округ | 43 |
| 5 | Юго-Восточный административный округ | 39 |
| 6 | Западный административный округ | 35 |
| 7 | Северо-Западный административный округ | 19 |
| 8 | Юго-Западный административный округ | 18 |
'Какие категории заведений на этих улицы'
| category | count | |
|---|---|---|
| 0 | кафе | 160 |
| 1 | ресторан | 93 |
| 2 | кофейня | 84 |
| 3 | бар,паб | 39 |
| 4 | столовая | 36 |
| 5 | быстрое питание | 23 |
| 6 | пиццерия | 15 |
| 7 | булочная | 8 |
'Много ли на них заведений, работающих 24/7'
| is_24_7 | count | |
|---|---|---|
| 0 | False | 427 |
| 1 | True | 31 |
'Много ли на них сетевых заведений'
| chain | count | |
|---|---|---|
| 0 | False | 326 |
| 1 | True | 132 |
'Средний рейтинг "одиноких" заведений: - '
4.236681222707423
'Какие ценовые категории заведений там представлены'
| price | count | |
|---|---|---|
| 0 | средние | 164 |
| 1 | выше среднего | 36 |
| 2 | высокие | 22 |
| 3 | низкие | 22 |
'Среднее количество мест в "одиноких" заведенях: - '
61.48
Сначала выведем таблицу медианы средних чеков по каждому району, а затем положимм эти данные на карту для наглядности.
# подготовим датафрейм со медианами значениями средних чеков по районам
avg_bill_district = ( df.groupby('district')
.agg(district_bill=('middle_avg_bill','median'))
.sort_values(by='district_bill', ascending=False)
.reset_index()
)
display(avg_bill_district)
| district | district_bill | |
|---|---|---|
| 0 | Западный административный округ | 1000.0 |
| 1 | Центральный административный округ | 1000.0 |
| 2 | Северо-Западный административный округ | 700.0 |
| 3 | Северный административный округ | 650.0 |
| 4 | Юго-Западный административный округ | 600.0 |
| 5 | Восточный административный округ | 550.0 |
| 6 | Северо-Восточный административный округ | 500.0 |
| 7 | Южный административный округ | 500.0 |
| 8 | Юго-Восточный административный округ | 450.0 |
# Преобразуем данные о районе и среднем чеке в словарь для быстрого поиска
rating_dict = dict(zip(avg_bill_district['district'], avg_bill_district['district_bill']))
moscow_lat, moscow_lng = 55.751244, 37.618423
state_geo_url = 'https://code.s3.yandex.net/data-analyst/admin_level_geomap.geojson'
response = requests.get(state_geo_url)
state_geo = response.json()
for feature in state_geo['features']:
district_name = feature['properties']['name']
feature['properties']['rating'] = rating_dict.get(district_name, 'Нет данных')
m = folium.Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# сначала создаем FeatureGroup для хороплета и данных JSON
districts = FeatureGroup(name='Районы и средний чек', overlay=True, control=True, show=True)
folium.Choropleth(
geo_data=state_geo,
name="choropleth",
data=avg_bill_district,
columns=["district", "district_bill"],
key_on="feature.properties.name", # уточнение пути к полю, по котторому хороплет сопоставляет гео данные и данные из ДФ
fill_color="YlOrRd",
fill_opacity=0.5,
line_opacity=0.1,
# legend_name="Средний чек в заведениях района Москвы", - не отображается вместе с цветовой шкалой, ее делаем отдельно ниже
).geojson.add_to(districts) # важно поместить хороплет в FeatureGroup через .geojson, иначе не работает
# выводим данные о названии района и среднем чеке в поп-апе
folium.GeoJson(
state_geo,
style_function=lambda feature: {
'fillColor': 'transparent',
'color': 'transparent',
'weight': 0
},
name="geojson",
tooltip=folium.features.GeoJsonTooltip(
fields=['name', 'rating'],
aliases=['Район: ', 'Средний чек: '],
localize=True
),
popup=folium.GeoJsonPopup(
fields=['name', 'rating'],
aliases=['Район: ', 'Средний чек: '],
localize=True
)
).add_to(districts) # помещаем в тот же слой для управления
# добавляем слой с районами и подписями на карту
districts.add_to(m)
# Добавляем элемент управления слоями и указываем позицию
folium.LayerControl(position='topleft').add_to(m)
folium.TileLayer('Cartodb Positron',overlay=False, show=False, control = False).add_to(m)
# вариант добавления градиентной шкалы вручную - она не отображется, если хороплет добавлять в слой через geojson
colormap = linear.YlOrRd_09.scale(avg_bill_district['district_bill'].min(), avg_bill_district['district_bill'].max())
colormap = colormap.to_step(n=10)
colormap.caption = 'Средний чек заведений по районам'
colormap.add_to(m)
# Создание HTML заголовка, в Folium не нашел готовый арибут title, как на графиках
# еще бы здорово было убрать вертикальную полосу прокрутки, сдвинуть заголовок вниз на карту и шкалу ниже опустить,
# но я потратил 2 дня на поиски решения без особого успеха
title_html = '''
<div style="position: relative; width: 100%; background-color: transparent; z-index: 1000; text-align: center;">
<h3 style="margin: 0; padding: 10px; font: calibri, font-size: 20px;">
<b>Средние чеки по районам Москвы</b></h3>
</div>
'''
# Добавление заголовка на карту
m.get_root().html.add_child(folium.Element(title_html))
# выводим карту
m
Посмотрим на то, какие категории заведений чаще всего работают 24/7. Думаю - это кафе!
overnight_categories = (
df.query('is_24_7==True', engine='python')
.groupby('category')
.agg(count_24_7=('name','count'))
.sort_values(by='count_24_7', ascending=False)
.reset_index()
)
total_categories = (
df.groupby('category')
.agg(count_total=('name','count'))
.sort_values(by='count_total', ascending=False)
.reset_index()
)
merged = overnight_categories.merge(total_categories, how='left', left_on='category', right_on='category')
merged['share'] = round(merged['count_24_7'] * 100 / merged['count_total'], 2)
display(merged.sort_values(by='share', ascending=False))
| category | count_24_7 | count_total | share | |
|---|---|---|---|---|
| 1 | быстрое питание | 150 | 602 | 24.92 |
| 0 | кафе | 266 | 2366 | 11.24 |
| 6 | булочная | 24 | 255 | 9.41 |
| 4 | бар,паб | 52 | 756 | 6.88 |
| 2 | ресторан | 133 | 2031 | 6.55 |
| 5 | пиццерия | 31 | 629 | 4.93 |
| 3 | кофейня | 59 | 1401 | 4.21 |
| 7 | столовая | 12 | 313 | 3.83 |
Посмотрим на расположение заведений, работающих 24/7. Думаю - в лидерах будет Центральный район!
overnight_district = (
df.query('is_24_7==True', engine='python')
.groupby('district')
.agg(count_24_7=('name','count'))
.sort_values(by='count_24_7', ascending=False)
.reset_index()
)
total_district = (
df.groupby('district')
.agg(count_total=('name','count'))
.sort_values(by='count_total', ascending=False)
.reset_index()
)
merged = overnight_district.merge(total_district, how='left', left_on='district', right_on='district')
merged['share'] = round(merged['count_24_7'] * 100 / merged['count_total'], 2)
display(merged.sort_values(by='share', ascending=False))
| district | count_24_7 | count_total | share | |
|---|---|---|---|---|
| 2 | Юго-Восточный административный округ | 93 | 714 | 13.03 |
| 1 | Восточный административный округ | 97 | 791 | 12.26 |
| 8 | Северо-Западный административный округ | 43 | 409 | 10.51 |
| 5 | Юго-Западный административный округ | 73 | 707 | 10.33 |
| 3 | Северо-Восточный административный округ | 75 | 891 | 8.42 |
| 4 | Южный административный округ | 75 | 891 | 8.42 |
| 6 | Западный административный округ | 70 | 831 | 8.42 |
| 7 | Северный административный округ | 70 | 877 | 7.98 |
| 0 | Центральный административный округ | 131 | 2242 | 5.84 |
Ну и давайте посмотрим на заведения с самыми плохими и самыми хорошими рейтингами
worst_rating = df.query('rating < 2', engine='python')
print('Заведений с рейтингом меньше 2: ', worst_rating.shape[0])
Заведений с рейтингом меньше 2: 63
best_rating = df.query('rating == 5', engine='python')
print('Заведений с максимальным рейтингом 5: ', best_rating.shape[0])
Заведений с максимальным рейтингом 5: 105
посмотрим на их средние чеки, районы и категории
print('Средний чек худших заведений: ', worst_rating['middle_avg_bill'].median())
print('Средний чек лучших заведений: ', best_rating['middle_avg_bill'].median())
Средний чек худших заведений: 700.0 Средний чек лучших заведений: 500.0
На заметку начинающему ресторатору: если у тебя рейтинг ниже плинтуса - не стесняйся, поднимай цены, меньше посетителей не станет 😊😊😊
В каких районах расположены лучшие и худшие заведения
worst_districts = (
worst_rating.groupby('district')
.agg(count=('name','count'))
.sort_values(by='count', ascending=False)
.reset_index()
)
display(worst_districts)
| district | count | |
|---|---|---|
| 0 | Юго-Восточный административный округ | 17 |
| 1 | Северо-Восточный административный округ | 15 |
| 2 | Юго-Западный административный округ | 10 |
| 3 | Западный административный округ | 8 |
| 4 | Южный административный округ | 5 |
| 5 | Восточный административный округ | 3 |
| 6 | Северный административный округ | 2 |
| 7 | Северо-Западный административный округ | 2 |
| 8 | Центральный административный округ | 1 |
best_districts = (
best_rating.groupby('district')
.agg(count=('name','count'))
.sort_values(by='count', ascending=False)
.reset_index()
)
display(best_districts)
| district | count | |
|---|---|---|
| 0 | Северный административный округ | 27 |
| 1 | Центральный административный округ | 20 |
| 2 | Южный административный округ | 11 |
| 3 | Северо-Западный административный округ | 10 |
| 4 | Восточный административный округ | 9 |
| 5 | Юго-Восточный административный округ | 8 |
| 6 | Юго-Западный административный округ | 8 |
| 7 | Западный административный округ | 7 |
| 8 | Северо-Восточный административный округ | 5 |
Посмотрим на то, как худшие и лучшие заведения распределены по категориям
best_categories = (
best_rating.groupby('category')
.agg(count=('name','count'))
.sort_values(by='count', ascending=False)
.reset_index()
)
total_categories = (
df.groupby('category')
.agg(total=('name','count'))
.sort_values(by='total', ascending=False)
.reset_index()
)
best_categories = (
best_categories.merge(total_categories, how='left', left_on='category', right_on='category')
.reset_index(drop=True)
)
best_categories['share'] = round(best_categories['count'] * 100 / best_categories['total'], 2)
display(best_categories.sort_values(by='share', ascending=False))
| category | count | total | share | |
|---|---|---|---|---|
| 0 | кофейня | 37 | 1401 | 2.64 |
| 1 | кафе | 30 | 2366 | 1.27 |
| 4 | пиццерия | 7 | 629 | 1.11 |
| 5 | быстрое питание | 6 | 602 | 1.00 |
| 3 | бар,паб | 7 | 756 | 0.93 |
| 2 | ресторан | 15 | 2031 | 0.74 |
| 6 | столовая | 2 | 313 | 0.64 |
| 7 | булочная | 1 | 255 | 0.39 |
worst_categories = (
worst_rating.groupby('category')
.agg(count=('name','count'))
.sort_values(by='count', ascending=False)
.reset_index()
)
worst_categories = (
worst_categories.merge(total_categories, how='left', left_on='category', right_on='category')
.reset_index(drop=True)
)
worst_categories['share'] = round(worst_categories['count'] * 100 / worst_categories['total'], 2)
display(worst_categories.sort_values(by='share', ascending=False))
| category | count | total | share | |
|---|---|---|---|---|
| 0 | кафе | 32 | 2366 | 1.35 |
| 2 | быстрое питание | 7 | 602 | 1.16 |
| 4 | столовая | 3 | 313 | 0.96 |
| 3 | бар,паб | 5 | 756 | 0.66 |
| 1 | ресторан | 13 | 2031 | 0.64 |
| 5 | булочная | 1 | 255 | 0.39 |
| 7 | пиццерия | 1 | 629 | 0.16 |
| 6 | кофейня | 1 | 1401 | 0.07 |
Мы провели углубленное исследование доступных нам данных о заведениях общественного питания Москвы и можем обозначит следующие важные закономерности:
Попробуем оттолкнуться от этих вводных и сформировать предложения инвесторам. Для начала, соберем воедино статистику о существующих кофейнях из базы
# отберем все кофейни в отдельный ДФ для дальнейшей работы
coffee_house = df.query('category == "кофейня"', engine='python').reset_index(drop=True)
print(f'В нашей базе данных {coffee_house.shape[0]} кофеен')
В нашей базе данных 1401 кофеен
print('Распределение кофеен по районам:')
display(
coffee_house.groupby('district').agg(count=('name','count')).sort_values(by='count', ascending=False).reset_index()
)
Распределение кофеен по районам:
| district | count | |
|---|---|---|
| 0 | Центральный административный округ | 428 |
| 1 | Северный административный округ | 186 |
| 2 | Северо-Восточный административный округ | 159 |
| 3 | Западный административный округ | 146 |
| 4 | Южный административный округ | 131 |
| 5 | Восточный административный округ | 104 |
| 6 | Юго-Западный административный округ | 96 |
| 7 | Юго-Восточный административный округ | 89 |
| 8 | Северо-Западный административный округ | 62 |
Для этого надо загрузить и парсить информацию из интернета: я скачал GPX-файл со станциями метро и их координатами с Википедии (https://en.wikipedia.org/wiki/Moscow_Metro), загрузил его в рабочую директорию в Jupyter.
# ЭТО ЕСЛИ ФАЙЛ ЗАЛИТ В ДИРЕКТОРИЮ НА СЕРВЕР, А НЕ ПО ССЫЛКЕ URL
# import os
# # Показать текущую рабочую директорию
# print("Текущая рабочая директория:", os.getcwd())
# # Проверка существования файла в текущей рабочей директории
# file_path = "Category_Moscow_Metro_stations.gpx"
# if os.path.isfile(file_path):
# print(f"Файл '{file_path}' найден в текущей рабочей директории.")
# else:
# print(f"Файл '{file_path}' не найден в текущей рабочей директории.")
# metro_url = 'https://geoexport.toolforge.org/gpx?coprimary=primary&titles=Category%3AMoscow+Metro+stations'
# metro = requests.get(metro_url)
# metro_url = 'https://geoexport.toolforge.org/gpx?coprimary=primary&titles=Category%3AMoscow+Metro+stations'
# metro = requests.get(metro_url)
# import urllib.request
# import gpxpy
# import pandas as pd
# URL файла GPX
url = "https://geoexport.toolforge.org/gpx?coprimary=primary&titles=Category%3AMoscow+Metro+stations"
# Скачивание GPX файла
try:
with urllib.request.urlopen(url) as response:
gpx_data = response.read().decode()
print("GPX файл загружен успешно.")
except Exception as e:
print(f"Ошибка при загрузке файла: {e}")
# Проверка содержимого GPX файла
if gpx_data:
print("GPX данные получены, длина данных:", len(gpx_data))
else:
print("GPX данные не получены.")
# Разбор GPX файла
try:
gpx = gpxpy.parse(gpx_data)
print("GPX файл разобран успешно.")
except Exception as e:
print(f"Ошибка при разборе GPX файла: {e}")
# Инициализация списков для данных
lats = []
lons = []
names = []
# Извлечение информации из waypoints
if gpx.waypoints:
for waypoint in gpx.waypoints:
lats.append(waypoint.latitude)
lons.append(waypoint.longitude)
names.append(waypoint.name if waypoint.name else "Unknown")
# Создание DataFrame из полученных данных
data = {
'metro': names,
'lat': lats,
'lng': lons
}
df_metro = pd.DataFrame(data)
# Печать DataFrame для проверки
display(df_metro)
GPX файл загружен успешно. GPX данные получены, длина данных: 165770 GPX файл разобран успешно.
| metro | lat | lng | |
|---|---|---|---|
| 0 | Aeroport (Moscow Metro) | 55.800300 | 37.532900 |
| 1 | Aeroport Vnukovo (Moscow Metro) | 55.606667 | 37.288333 |
| 2 | Akademicheskaya (Kaluzhsko-Rizhskaya line) | 55.687700 | 37.573300 |
| 3 | Aleksandrovsky Sad (Moscow Metro) | 55.752500 | 37.608500 |
| 4 | Alekseyevskaya (Moscow Metro) | 55.808800 | 37.639000 |
| ... | ... | ... | ... |
| 610 | Ugreshskaya | 55.718500 | 37.697600 |
| 611 | Verkhniye Kotly | 55.690200 | 37.619300 |
| 612 | Vladykino (Moscow Central Circle) | 55.847400 | 37.592600 |
| 613 | ZIL (Moscow Central Circle) | 55.697800 | 37.647400 |
| 614 | Zorge (Moscow Central Circle) | 55.787800 | 37.504500 |
615 rows × 3 columns
# подготовим датафрейм со средними значениями рейтингов кофеен по районам
avg_capuccino_district = ( df.groupby('district')
.agg(district_coffee_cup=('middle_coffee_cup','mean'))
.reset_index()
)
display(avg_capuccino_district)
| district | district_coffee_cup | |
|---|---|---|
| 0 | Восточный административный округ | 174.023810 |
| 1 | Западный административный округ | 188.285714 |
| 2 | Северный административный округ | 165.583333 |
| 3 | Северо-Восточный административный округ | 165.333333 |
| 4 | Северо-Западный административный округ | 160.458333 |
| 5 | Центральный административный округ | 188.210843 |
| 6 | Юго-Восточный административный округ | 150.771429 |
| 7 | Юго-Западный административный округ | 183.485714 |
| 8 | Южный административный округ | 157.826087 |
# Преобразуем данные о районе и средней стоимости кофе в словарь для быстрого поиска
cappuchino_dict = dict(zip(avg_capuccino_district['district'], avg_capuccino_district['district_coffee_cup']))
moscow_lat, moscow_lng = 55.751244, 37.618423
state_geo_url = 'https://code.s3.yandex.net/data-analyst/admin_level_geomap.geojson'
response = requests.get(state_geo_url)
state_geo = response.json()
# заносим в атрибут rating в JSON файл данные из нашего DF (средняя стоимость кофе),
# из словаря по ключу = название района (name = index)
for feature in state_geo['features']:
district_name = feature['properties']['name']
feature['properties']['rating'] = cappuchino_dict.get(district_name, 'Нет данных')
m = folium.Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# сначала создаем FeatureGroup для кофеен
coffee_markers = FeatureGroup(name='Кофейни', overlay=True, control=True, show=True)
# затем привязываем к нему создаваемые кластеры
marker_cluster = MarkerCluster().add_to(coffee_markers)
# Создаем FeatureGroup для метро
metro_group = FeatureGroup(name='Метро')
# Создаем FeatureGroup для райнов
district_group = FeatureGroup(name='Район')
#------------------------------------------------------------------
# пишем функцию, которая принимает строку датафрейма,
# создаёт маркер в текущей точке и добавляет его в кластер marker_cluster
def create_clusters(row):
# Создаем HTML-контент для pop-up, чтобы симпатичнее была представлена информация: название, рейтинг, категория заведения
popup_html = f"""
<b>{row['name']}</b><br>
{row['rating']} ★<br>
{row['category']}
"""
# Создаем и добавляем маркер к кластеру
Marker(
[row['lat'], row['lng']],
popup=folium.Popup(popup_html, max_width=300)
).add_to(marker_cluster)
# применяем функцию create_clusters() к каждой строке датафрейма c нашими кофейнями
coffee_house.apply(create_clusters, axis=1)
# Создаем хороплет с районами
choropleth = Choropleth(
geo_data=state_geo,
data=avg_capuccino_district,
columns=['district', 'district_coffee_cup'],
key_on='feature.properties.name',
fill_color='YlOrRd',
fill_opacity=0.5,
line_opacity=0.1,
legend_name='Средняя цена капучино по районам',
).geojson.add_to(district_group)
# выводим данные о названии района и среднем кофе в поп-апе
folium.GeoJson(
state_geo,
style_function=lambda feature: {
'fillColor': 'transparent',
'color': 'transparent',
'weight': 0
},
name="geojson",
tooltip=folium.features.GeoJsonTooltip(
fields=['name', 'rating'],
aliases=['Район: ', 'Средняя цена капучино: '],
localize=True
),
popup=folium.GeoJsonPopup(
fields=['name', 'rating'],
aliases=['Район: ', 'Средняя цена капучино: '],
localize=True
)
).add_to(district_group) # помещаем в тот же слой для управления
# добавляем слой с районами и подписями на карту
district_group.add_to(m)
#------------------------------------------------------------------
# пишем функцию для создания маркеров метро
def create_metro_markers(row):
# сохраняем URL-адрес изображения со значком метро с icons8,
# это путь к файлу на сервере icons8
icon_url = 'https://img.icons8.com/?size=100&id=0W3r2VtGfMg3&format=png&color=000000'
# создаём объект с собственной иконкой размером 15x15
icon = CustomIcon(icon_url, icon_size=(15, 15))
# создаём маркер с иконкой icon и добавляем его в metro_group
Marker(
[row['lat'], row['lng']],
popup=f"{row['metro']}",
icon=icon,
).add_to(metro_group)
# применяем функцию для создания маркеров метро к каждой строке датафрейма
df_metro.apply(create_metro_markers, axis=1)
# Добавляем metro_group на карту
metro_group.add_to(m)
# Добавляем districts_group на карту
district_group.add_to(m)
# Добавляем coffee_markers на карту
coffee_markers.add_to(m)
# Добавляем элемент управления слоями и указываем позицию
folium.LayerControl(position='topleft').add_to(m)
folium.TileLayer('Cartodb Positron',overlay=False, show=False, control = False).add_to(m)
# вариант добавления градиентной шкалы вручную - она не отображется, если хороплет добавлять в слой через geojson
colormap = linear.YlOrRd_09.scale(avg_capuccino_district['district_coffee_cup'].min(), avg_capuccino_district['district_coffee_cup'].max())
colormap = colormap.to_step(n=10)
colormap.caption = 'Средняя цена капучино по районам'
colormap.add_to(m)
# Создание HTML заголовка, в Folium не нашел готовый арибут title, как на графиках
# еще бы здорово было убрать вертикальную полосу прокрутки, сдвинуть заголовок вниз на карту и шкалу ниже опустить,
# но я потратил 2 дня на поиски решения без особого успеха
title_html = '''
<div style="position: relative; width: 100%; background-color: transparent; z-index: 1000; text-align: center;">
<h3 style="margin: 0; padding: 10px; font: calibri, font-size: 20px;">
<b>Средняя цена капучино по районам Москвы</b></h3>
</div>
'''
# Добавление заголовка на карту
m.get_root().html.add_child(folium.Element(title_html))
# выводим карту
m
Посмотрим, есть ли круглосуточные кофейни и где?
print('Распределение круглосуточных кофеен по районам:')
display(
coffee_house.query('is_24_7 == True', engine='python').groupby('district').agg(count=('name','count')).sort_values(by='count', ascending=False).reset_index()
)
Распределение круглосуточных кофеен по районам:
| district | count | |
|---|---|---|
| 0 | Центральный административный округ | 26 |
| 1 | Западный административный округ | 9 |
| 2 | Юго-Западный административный округ | 7 |
| 3 | Восточный административный округ | 5 |
| 4 | Северный административный округ | 5 |
| 5 | Северо-Восточный административный округ | 3 |
| 6 | Северо-Западный административный округ | 2 |
| 7 | Юго-Восточный административный округ | 1 |
| 8 | Южный административный округ | 1 |
и наконец, посмотрим на средний чек, среднюю цену капучино, среднее количество мест у кофеен с рейтингом более 4.5 в Центральном районе в ценовом рейтинге выше среднего/высоком.
central_5star_coffe_house = df.query('category == "кофейня" '
'and district == "Центральный административный округ" '
'and rating >= 4.5 ', engine='python'
)
# Вывод статистического описания
central_5star_coffe_house.describe()
| index | lat | lng | rating | middle_avg_bill | middle_coffee_cup | seats | category_numeric | district_numeric | |
|---|---|---|---|---|---|---|---|---|---|
| count | 105.000000 | 105.000000 | 105.000000 | 105.000000 | 21.000000 | 42.000000 | 47.000000 | 105.0 | 105.0 |
| mean | 4005.180952 | 55.757886 | 37.625115 | 4.645714 | 874.047619 | 209.023810 | 97.319149 | 3.0 | 5.0 |
| std | 1047.029037 | 0.017588 | 0.031528 | 0.150658 | 693.771611 | 45.684757 | 77.144727 | 0.0 | 0.0 |
| min | 1681.000000 | 55.713716 | 37.550790 | 4.500000 | 0.000000 | 149.000000 | 3.000000 | 3.0 | 5.0 |
| 25% | 3538.000000 | 55.743438 | 37.601427 | 4.500000 | 400.000000 | 171.750000 | 36.500000 | 3.0 | 5.0 |
| 50% | 4410.000000 | 55.759976 | 37.627119 | 4.600000 | 500.000000 | 197.500000 | 85.000000 | 3.0 | 5.0 |
| 75% | 4818.000000 | 55.771566 | 37.645842 | 4.700000 | 1250.000000 | 250.000000 | 149.000000 | 3.0 | 5.0 |
| max | 6202.000000 | 55.786824 | 37.697890 | 5.000000 | 2500.000000 | 300.000000 | 295.000000 | 3.0 | 5.0 |
Средние значения заведений, нас интересующих:
В качестве рекомендаций по открытию новой кофейни можем сказать следующее:
Есть несколько способов измерить трафик и прикинуть, сколько чашек кофе в день вы можете продавать:
Следует оценить платежеспособность аудитории. Есть несколько маркеров: стоимость квадратного метра и аренды, марки автомобилей, цены в магазинах и соседних ресторанах, транспортная доступность.
Изучить конкурентов. Если рядом жесткие дискаунтеры, конкурировать будет сложно. Если кофейни среднего ценового сегмента — надо выделиться на их фоне необычным ассортиментом, бесплатными добавками или программой лояльности.
презентация данного исследования доступна для скачивания по ссылке: https://disk.yandex.ru/i/wnfXXG6At0UYyw